mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
fix: stop IPC ghost listeners leaking across contextBridge calls (#2131)
contextBridge wraps every function crossing the world boundary in a fresh proxy object, so ipcRenderer.on() and removeListener() each receive a different wrapper and reference-based removal always fails. Components that subscribe in useEffect then return removeListener as cleanup leave a ghost listener behind after unmount. The connections page is the worst offender: the /connections stream pushes the full connection list every second, and each ghost handler dispatches a fresh connection tree into a React update queue that is never rendered — heap grows ~22-25MB/min after visiting the page a few times, scaling with the number of visits. Make on() return an unsubscribe closure that captures the exact wrapper registered with ipcRenderer, and switch every renderer call site to use it as its effect cleanup. Verified with a minimal Electron harness (removed listener stops firing) and against the real app: hammering the connections page 20x now leaves memory flat instead of growing ~25MB/min, with zero connections-chunk allocations sampled. Co-authored-by: tuqiming <tuqiming@camel4u> Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -1,11 +1,12 @@
|
||||
import { webUtils } from 'electron'
|
||||
|
||||
type IpcListener = (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||
type IpcUnsubscribe = () => void
|
||||
|
||||
interface SafeIpcRenderer {
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
|
||||
send: (channel: string, ...args: unknown[]) => void
|
||||
on: (channel: string, listener: IpcListener) => void
|
||||
on: (channel: string, listener: IpcListener) => IpcUnsubscribe
|
||||
removeListener: (channel: string, listener: IpcListener) => void
|
||||
removeAllListeners: (channel: string) => void
|
||||
}
|
||||
|
||||
@@ -199,6 +199,13 @@ type ListenChannel = (typeof validListenChannels)[number]
|
||||
type SendChannel = (typeof validSendChannels)[number]
|
||||
|
||||
type IpcListener = (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||
type IpcUnsubscribe = () => void
|
||||
|
||||
// contextBridge 每次把渲染层函数传进 preload 世界时都会生成一个新的代理包装,
|
||||
// on() 与 removeListener() 收到的包装对象并不相同,跨调用按引用匹配永远无法移除监听器。
|
||||
// 监听器残留后组件卸载仍在消费 IPC 消息(连接页每秒收到全量连接列表),
|
||||
// 对已卸载组件持续 dispatch,update 挂在 React 内部队列上无人消费,内存无界增长。
|
||||
// 因此 on() 返回一个取消闭包,闭包捕获本次实际注册进 ipcRenderer 的代理,跨调用移除可靠。
|
||||
const listenerMap = new Map<ListenChannel, Set<IpcListener>>()
|
||||
|
||||
// 安全的 IPC API,只暴露白名单内的 channels
|
||||
@@ -215,19 +222,31 @@ const electronAPI = {
|
||||
ipcRenderer.send(channel, ...args)
|
||||
}
|
||||
},
|
||||
on: (channel: ListenChannel, listener: IpcListener): void => {
|
||||
on: (channel: ListenChannel, listener: IpcListener): IpcUnsubscribe => {
|
||||
if (validListenChannels.includes(channel)) {
|
||||
if (!listenerMap.has(channel)) {
|
||||
listenerMap.set(channel, new Set())
|
||||
}
|
||||
listenerMap.get(channel)?.add(listener)
|
||||
ipcRenderer.on(channel, listener)
|
||||
let byChannel = listenerMap.get(channel)
|
||||
if (!byChannel) {
|
||||
byChannel = new Set()
|
||||
listenerMap.set(channel, byChannel)
|
||||
}
|
||||
byChannel.add(listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
byChannel.delete(listener)
|
||||
if (byChannel.size === 0) {
|
||||
listenerMap.delete(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
removeListener: (channel: ListenChannel, listener: IpcListener): void => {
|
||||
if (validListenChannels.includes(channel)) {
|
||||
listenerMap.get(channel)?.delete(listener)
|
||||
// 仅移除同一次调用注册的监听器。跨调用场景请使用 on() 返回的取消闭包,
|
||||
// 因为 contextBridge 每次传函数都会生成新代理,这里无法匹配旧代理。
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
listenerMap.get(channel)?.delete(listener)
|
||||
}
|
||||
},
|
||||
removeAllListeners: (channel: ListenChannel): void => {
|
||||
|
||||
@@ -55,10 +55,8 @@ const FloatingApp: React.FC = () => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic)
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('mihomoTraffic', handleTraffic)
|
||||
}
|
||||
const unsubscribe = window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic)
|
||||
return unsubscribe
|
||||
}, [handleTraffic])
|
||||
|
||||
return (
|
||||
|
||||
@@ -265,10 +265,7 @@ const NetworkTopologyCard: React.FC = () => {
|
||||
const info = args[0] as IMihomoConnectionsInfo
|
||||
setConnections(info.connections ?? [])
|
||||
}
|
||||
window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener('mihomoConnections', handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
}, [isPaused])
|
||||
|
||||
const currentConnections = isPaused && frozenRef.current ? frozenRef.current : connections
|
||||
|
||||
@@ -152,10 +152,7 @@ const ConnCard: React.FC<Props> = (props) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic)
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('mihomoTraffic', handleTraffic)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic)
|
||||
}, [handleTraffic])
|
||||
|
||||
// showTraffic 开关切换时统一管理托盘图标
|
||||
|
||||
@@ -48,10 +48,10 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
|
||||
const info = args[0] as IMihomoMemoryInfo
|
||||
setMem(info.inuse)
|
||||
}
|
||||
window.electron.ipcRenderer.on('mihomoMemory', onMemory)
|
||||
const unsubscribeMemory = window.electron.ipcRenderer.on('mihomoMemory', onMemory)
|
||||
return (): void => {
|
||||
PubSub.unsubscribe(token)
|
||||
window.electron.ipcRenderer.removeListener('mihomoMemory', onMemory)
|
||||
unsubscribeMemory()
|
||||
}
|
||||
}, [mutate])
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ const UpdaterModal: React.FC<Props> = (props) => {
|
||||
const handler = (_e: Electron.IpcRendererEvent, ...args: unknown[]): void => {
|
||||
setProgress(args[0] as { status: 'downloading' | 'verifying'; percent?: number })
|
||||
}
|
||||
window.electron.ipcRenderer.on('updateDownloadProgress', handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener('updateDownloadProgress', handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('updateDownloadProgress', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,10 +26,7 @@ export function createConfigContext<T>(options: CreateConfigContextOptions<T>) {
|
||||
const handler = (): void => {
|
||||
mutate()
|
||||
}
|
||||
window.electron.ipcRenderer.on(ipcEvent, handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener(ipcEvent, handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on(ipcEvent, handler)
|
||||
}, [mutate])
|
||||
|
||||
return <Context.Provider value={{ config, mutate }}>{children}</Context.Provider>
|
||||
|
||||
@@ -35,10 +35,7 @@ export const ControledMihomoConfigProvider: React.FC<{ children: ReactNode }> =
|
||||
const handler = (): void => {
|
||||
mutateControledMihomoConfig()
|
||||
}
|
||||
window.electron.ipcRenderer.on('controledMihomoConfigUpdated', handler)
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('controledMihomoConfigUpdated', handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('controledMihomoConfigUpdated', handler)
|
||||
}, [mutateControledMihomoConfig])
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,10 +30,7 @@ export const GroupsProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
const handler = (): void => {
|
||||
mutate()
|
||||
}
|
||||
window.electron.ipcRenderer.on('groupsUpdated', handler)
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('groupsUpdated', handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('groupsUpdated', handler)
|
||||
}, [mutate])
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,10 +19,7 @@ export const RulesProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
const handler = (): void => {
|
||||
mutate()
|
||||
}
|
||||
window.electron.ipcRenderer.on('rulesUpdated', handler)
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('rulesUpdated', handler)
|
||||
}
|
||||
return window.electron.ipcRenderer.on('rulesUpdated', handler)
|
||||
}, [mutate])
|
||||
|
||||
return <RulesContext.Provider value={{ rules, mutate }}>{children}</RulesContext.Provider>
|
||||
|
||||
@@ -78,12 +78,12 @@ export function useTrafficLogger(enabled = true): void {
|
||||
void legacyTrafficUsageDatabase
|
||||
.migrateLegacyLogs()
|
||||
.catch((error) => console.error('[TrafficLogger] migration failed', error))
|
||||
window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
const unsubscribe = window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
|
||||
return (): void => {
|
||||
disposed = true
|
||||
clearFlushTimer()
|
||||
window.electron.ipcRenderer.removeListener('mihomoConnections', handler)
|
||||
unsubscribe()
|
||||
accumulator.setEnabled(false)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
@@ -503,12 +503,13 @@ const Connections: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
if (!isPaused) {
|
||||
window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
unsubscribe = window.electron.ipcRenderer.on('mihomoConnections', handler)
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeListener('mihomoConnections', handler)
|
||||
unsubscribe?.()
|
||||
if (frameId !== undefined) window.cancelAnimationFrame(frameId)
|
||||
pendingInfo = undefined
|
||||
}
|
||||
|
||||
@@ -40,11 +40,11 @@ const onLog = (_e: unknown, ...args: unknown[]): void => {
|
||||
// Keep streaming while this page is hidden so returning users can see intervening logs.
|
||||
// The session cache is bounded by MAX_CACHED_LOGS, so stopping on unmount hurts UX
|
||||
// without providing meaningful memory savings.
|
||||
window.electron.ipcRenderer.on('mihomoLogs', onLog)
|
||||
const unsubscribeLogs = window.electron.ipcRenderer.on('mihomoLogs', onLog)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
window.electron.ipcRenderer.removeListener('mihomoLogs', onLog)
|
||||
unsubscribeLogs()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user