Enhance memory monitoring and fix memory leaks

Co-authored-by: Kuingsmile <96409857+Kuingsmile@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-08-26 08:21:11 +00:00
parent 90bdbb6458
commit e5b9cf8b5b
6 changed files with 339 additions and 19 deletions

View File

@@ -17,17 +17,21 @@ import { T as $t } from '~/i18n'
import { getClipboardFilePath, showNotification } from '~/utils/common'
import { configPaths } from '~/utils/configPaths'
import { ICOREBuildInEvent, IWindowList } from '~/utils/enum'
import { IpcManager } from '~/utils/ipcManager'
import { CLIPBOARD_IMAGE_FOLDER } from '~/utils/static'
const waitForRename = (window: BrowserWindow, id: number): Promise<string | null> => {
return new Promise(resolve => {
ipcMain.once(`${RENAME_FILE_NAME}${id}`, (_: IpcMainEvent, newName: string) => {
// Use IpcManager for better cleanup tracking
const cleanup = IpcManager.once(`${RENAME_FILE_NAME}${id}`, (_: IpcMainEvent, newName: string) => {
resolve(newName)
window.close()
})
}, `rename-${id}`)
window.on('close', () => {
resolve(null)
ipcMain.removeAllListeners(`${RENAME_FILE_NAME}${id}`)
// Clean up the specific rename context
IpcManager.cleanupContext(`rename-${id}`)
windowManager.deleteById(window.id)
})
})
@@ -72,17 +76,21 @@ class Uploader {
: item.fileName
if (rename) {
const window = windowManager.create(IWindowList.RENAME_WINDOW)!
ipcMain.on(GET_RENAME_FILE_NAME, (evt, _) => {
const windowId = window.webContents.id
// Use IpcManager for safer listener management
IpcManager.on(GET_RENAME_FILE_NAME, (evt, _) => {
try {
if (evt.sender.id === window.webContents.id) {
if (evt.sender.id === windowId) {
logger.info('rename window ready, wait for rename...')
window.webContents.send(RENAME_FILE_NAME, fileName, item.fileName, window.webContents.id)
window.webContents.send(RENAME_FILE_NAME, fileName, item.fileName, windowId)
}
} catch (e: any) {
logger.error(e)
}
})
name = await waitForRename(window, window.webContents.id)
}, `rename-window-${windowId}`)
name = await waitForRename(window, windowId)
}
item.fileName = name || fileName
})
@@ -168,7 +176,8 @@ class Uploader {
}, 500)
return false
} finally {
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
// Use IpcManager for safer cleanup
IpcManager.removeAllListeners(GET_RENAME_FILE_NAME)
}
}
@@ -191,7 +200,8 @@ class Uploader {
}, 500)
return false
} finally {
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
// Use IpcManager for safer cleanup
IpcManager.removeAllListeners(GET_RENAME_FILE_NAME)
}
}
}

View File

@@ -166,8 +166,12 @@ class LifeCycle {
rpcServer.start()
busEventList.listen()
// Start memory monitoring in all environments
// More frequent monitoring in development, less frequent in production
if (process.env.NODE_ENV === 'development') {
MemoryMonitor.start(30000)
MemoryMonitor.start(30000) // Every 30 seconds in development
} else {
MemoryMonitor.start(300000) // Every 5 minutes in production
}
}

View File

@@ -0,0 +1,166 @@
import { ipcMain, IpcMainEvent } from 'electron'
/**
* IPC Manager utility to prevent memory leaks from accumulating IPC listeners
*/
export class IpcManager {
private static listeners = new Map<string, Set<{ event: string; handler: Function }>>()
/**
* Register an IPC listener with automatic cleanup tracking
*/
static on(channel: string, handler: (event: IpcMainEvent, ...args: any[]) => void, context?: string): () => void {
const contextKey = context || 'global'
if (!this.listeners.has(contextKey)) {
this.listeners.set(contextKey, new Set())
}
const listenerEntry = { event: channel, handler }
this.listeners.get(contextKey)!.add(listenerEntry)
ipcMain.on(channel, handler)
// Return cleanup function
return () => {
ipcMain.removeListener(channel, handler)
this.listeners.get(contextKey)?.delete(listenerEntry)
}
}
/**
* Register a one-time IPC listener with automatic cleanup tracking
*/
static once(channel: string, handler: (event: IpcMainEvent, ...args: any[]) => void, context?: string): () => void {
const contextKey = context || 'global'
if (!this.listeners.has(contextKey)) {
this.listeners.set(contextKey, new Set())
}
const wrappedHandler = (...args: Parameters<typeof handler>) => {
const listenerEntry = { event: channel, handler: wrappedHandler }
this.listeners.get(contextKey)?.delete(listenerEntry)
handler(...args)
}
const listenerEntry = { event: channel, handler: wrappedHandler }
this.listeners.get(contextKey)!.add(listenerEntry)
ipcMain.once(channel, wrappedHandler)
// Return cleanup function
return () => {
ipcMain.removeListener(channel, wrappedHandler)
this.listeners.get(contextKey)?.delete(listenerEntry)
}
}
/**
* Remove specific listener
*/
static removeListener(channel: string, handler: Function, context?: string) {
const contextKey = context || 'global'
ipcMain.removeListener(channel, handler as any)
const contextListeners = this.listeners.get(contextKey)
if (contextListeners) {
for (const listener of contextListeners) {
if (listener.event === channel && listener.handler === handler) {
contextListeners.delete(listener)
break
}
}
}
}
/**
* Clean up all listeners for a specific context
*/
static cleanupContext(context: string) {
const contextListeners = this.listeners.get(context)
if (!contextListeners) return
for (const listener of contextListeners) {
ipcMain.removeListener(listener.event, listener.handler as any)
}
this.listeners.delete(context)
}
/**
* Remove all listeners for a specific channel
*/
static removeAllListeners(channel: string) {
ipcMain.removeAllListeners(channel)
// Clean up from tracking
for (const [context, listeners] of this.listeners.entries()) {
const toRemove = Array.from(listeners).filter(l => l.event === channel)
toRemove.forEach(l => listeners.delete(l))
}
}
/**
* Get current listener statistics for debugging
*/
static getListenerStats() {
const stats: Record<string, { contexts: number; totalListeners: number; channels: string[] }> = {}
for (const [context, listeners] of this.listeners.entries()) {
const channels = Array.from(new Set(Array.from(listeners).map(l => l.event)))
if (!stats.global) stats.global = { contexts: 0, totalListeners: 0, channels: [] }
stats[context] = {
contexts: 1,
totalListeners: listeners.size,
channels
}
}
return stats
}
/**
* Clean up all tracked listeners (should be called on app quit)
*/
static cleanupAll() {
for (const context of this.listeners.keys()) {
this.cleanupContext(context)
}
}
/**
* Get potentially leaked listeners (ones that have been around too long)
*/
static detectPotentialLeaks(): { context: string; channel: string; count: number }[] {
const channelCounts = new Map<string, Map<string, number>>()
for (const [context, listeners] of this.listeners.entries()) {
if (!channelCounts.has(context)) {
channelCounts.set(context, new Map())
}
const contextChannels = channelCounts.get(context)!
for (const listener of listeners) {
const count = contextChannels.get(listener.event) || 0
contextChannels.set(listener.event, count + 1)
}
}
const potentialLeaks: { context: string; channel: string; count: number }[] = []
for (const [context, channels] of channelCounts.entries()) {
for (const [channel, count] of channels.entries()) {
// Flag channels with more than 10 listeners as potential leaks
if (count > 10) {
potentialLeaks.push({ context, channel, count })
}
}
}
return potentialLeaks
}
}

View File

@@ -1,6 +1,11 @@
import { IpcManager } from './ipcManager'
export class MemoryMonitor {
// eslint-disable-next-line no-undef
private static interval: NodeJS.Timeout | null = null
private static previousMemory: NodeJS.MemoryUsage | null = null
private static memoryHistory: Array<{ timestamp: number; memory: NodeJS.MemoryUsage }> = []
private static readonly HISTORY_LIMIT = 60 // Keep last 60 measurements for leak detection
static start(intervalMs: number = 30000) {
if (this.interval) return
@@ -13,14 +18,36 @@ export class MemoryMonitor {
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
external: Math.round(memUsage.external / 1024 / 1024)
}
// Track memory history for leak detection
this.memoryHistory.push({ timestamp: Date.now(), memory: memUsage })
if (this.memoryHistory.length > this.HISTORY_LIMIT) {
this.memoryHistory.shift()
}
// Detect memory leaks
const leakInfo = this.detectMemoryLeak()
if (leakInfo.hasLeak) {
console.warn(`[Memory Leak Detected] ${leakInfo.message}`)
}
// Check for IPC listener leaks
const ipcLeaks = IpcManager.detectPotentialLeaks()
if (ipcLeaks.length > 0) {
console.warn(`[IPC Leak Detected] ${ipcLeaks.length} potential IPC listener leaks:`, ipcLeaks)
}
console.log(
`[Memory] RSS: ${mbUsage.rss}MB, Heap: ${mbUsage.heapUsed}/${mbUsage.heapTotal}MB, External: ${mbUsage.external}MB`
`[Memory] RSS: ${mbUsage.rss}MB, Heap: ${mbUsage.heapUsed}/${mbUsage.heapTotal}MB, External: ${mbUsage.external}MB${leakInfo.hasLeak ? ' ⚠️' : ''}${ipcLeaks.length > 0 ? ' 📡' : ''}`
)
// Trigger garbage collection if memory usage is high
if (mbUsage.heapUsed / mbUsage.heapTotal > 0.8 && global.gc) {
console.log('[Memory] Triggering garbage collection')
global.gc()
}
this.previousMemory = memUsage
}, intervalMs)
}
@@ -29,5 +56,72 @@ export class MemoryMonitor {
clearInterval(this.interval)
this.interval = null
}
this.previousMemory = null
this.memoryHistory = []
// Clean up all IPC listeners when stopping monitoring
IpcManager.cleanupAll()
}
private static detectMemoryLeak(): { hasLeak: boolean; message: string } {
if (this.memoryHistory.length < 10) {
return { hasLeak: false, message: '' }
}
const recent = this.memoryHistory.slice(-10)
const oldest = this.memoryHistory[0]
// Check for steady memory growth over time
const rssGrowth = recent[recent.length - 1].memory.rss - oldest.memory.rss
const heapGrowth = recent[recent.length - 1].memory.heapUsed - oldest.memory.heapUsed
const externalGrowth = recent[recent.length - 1].memory.external - oldest.memory.external
const rssGrowthMB = Math.round(rssGrowth / 1024 / 1024)
const heapGrowthMB = Math.round(heapGrowth / 1024 / 1024)
const externalGrowthMB = Math.round(externalGrowth / 1024 / 1024)
// Thresholds for leak detection
const LEAK_THRESHOLD_MB = 50 // 50MB growth over monitoring period
if (rssGrowthMB > LEAK_THRESHOLD_MB) {
return {
hasLeak: true,
message: `RSS memory grew by ${rssGrowthMB}MB over monitoring period`
}
}
if (heapGrowthMB > LEAK_THRESHOLD_MB) {
return {
hasLeak: true,
message: `Heap memory grew by ${heapGrowthMB}MB over monitoring period`
}
}
if (externalGrowthMB > LEAK_THRESHOLD_MB) {
return {
hasLeak: true,
message: `External memory grew by ${externalGrowthMB}MB over monitoring period`
}
}
return { hasLeak: false, message: '' }
}
static getMemoryStats() {
if (this.memoryHistory.length === 0) return null
const current = this.memoryHistory[this.memoryHistory.length - 1]
const oldest = this.memoryHistory[0]
return {
current: current.memory,
growth: {
rss: Math.round((current.memory.rss - oldest.memory.rss) / 1024 / 1024),
heapUsed: Math.round((current.memory.heapUsed - oldest.memory.heapUsed) / 1024 / 1024),
external: Math.round((current.memory.external - oldest.memory.external) / 1024 / 1024)
},
historyLength: this.memoryHistory.length,
ipcStats: IpcManager.getListenerStats()
}
}
}

View File

@@ -155,6 +155,7 @@ function handlePageScroll() {
}
let ro: ResizeObserver | null = null
let documentRo: ResizeObserver | null = null
const lastScrollTime = ref(0)
function updateContainerMetrics() {
@@ -172,13 +173,22 @@ function updateContainerMetrics() {
onMounted(() => {
const el = containerRef.value
if (!el) return
// Set up main container ResizeObserver
ro = new ResizeObserver(updateContainerMetrics)
ro.observe(el)
if (props.pageMode) {
ro.observe(document.documentElement)
// Set up document ResizeObserver separately to avoid conflicts
documentRo = new ResizeObserver(updateContainerMetrics)
documentRo.observe(document.documentElement)
// Add scroll listeners with passive option for better performance
window.addEventListener('scroll', handlePageScroll, { passive: true })
// Find and add scroll listeners to parent elements
let parent = el.parentElement
while (parent) {
while (parent && parent !== document.documentElement) {
if (parent.scrollHeight > parent.clientHeight) {
parent.addEventListener('scroll', handlePageScroll, { passive: true })
parentScrollListeners.value.push(parent)
@@ -186,17 +196,32 @@ onMounted(() => {
parent = parent.parentElement
}
}
updateContainerMetrics()
if (props.pageMode) {
window.addEventListener('resize', updateContainerMetrics, { passive: true })
}
})
onBeforeUnmount(() => {
if (ro) ro.disconnect()
// Clean up ResizeObservers
if (ro) {
ro.disconnect()
ro = null
}
if (documentRo) {
documentRo.disconnect()
documentRo = null
}
// Clean up window listeners
window.removeEventListener('resize', updateContainerMetrics)
if (props.pageMode) {
window.removeEventListener('scroll', handlePageScroll)
// Clean up parent scroll listeners
parentScrollListeners.value.forEach(parent => {
parent.removeEventListener('scroll', handlePageScroll)
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, onUnmounted } from 'vue'
import type { IStringKeyMap } from '#/types/types'
@@ -12,6 +12,10 @@ export const useAppStore = defineStore('app', () => {
const loading = ref(false)
const error = ref<string | undefined>()
// Track media query listener for cleanup
let mediaQueryListener: ((e: MediaQueryListEvent) => void) | null = null
let mediaQuery: MediaQueryList | null = null
function clearError() {
error.value = undefined
}
@@ -24,22 +28,34 @@ export const useAppStore = defineStore('app', () => {
applyTheme(settings.value.app.theme || 'light')
}
function cleanupMediaQueryListener() {
if (mediaQuery && mediaQueryListener) {
mediaQuery.removeEventListener('change', mediaQueryListener)
mediaQuery = null
mediaQueryListener = null
}
}
function applyTheme(theme: string) {
const root = document.documentElement
root.classList.remove('light', 'dark', 'auto')
// Clean up existing listener before adding new one
cleanupMediaQueryListener()
if (theme === 'auto') {
root.classList.add('auto')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.add(prefersDark ? 'dark' : 'light')
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', e => {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQueryListener = (e: MediaQueryListEvent) => {
if (settings.value.app.theme === 'auto') {
root.classList.remove('light', 'dark')
root.classList.add(e.matches ? 'dark' : 'light')
}
})
}
mediaQuery.addEventListener('change', mediaQueryListener)
} else {
root.classList.add(theme)
}
@@ -68,6 +84,11 @@ export const useAppStore = defineStore('app', () => {
}
}
// Clean up when store is unmounted
onUnmounted(() => {
cleanupMediaQueryListener()
})
return {
init,
loadSettings,