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:
v-star0719
2026-09-03 21:47:31 +08:00
committed by GitHub
parent 0f26f22987
commit 2a19b02266
14 changed files with 45 additions and 47 deletions

View File

@@ -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
}

View File

@@ -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 消息(连接页每秒收到全量连接列表),
// 对已卸载组件持续 dispatchupdate 挂在 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 => {

View File

@@ -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 (

View File

@@ -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

View File

@@ -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 开关切换时统一管理托盘图标

View File

@@ -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])

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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])

View File

@@ -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
}

View File

@@ -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()
})
}