mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-05-06 20:42:57 +08:00
Enhance memory monitoring and fix memory leaks
Co-authored-by: Kuingsmile <96409857+Kuingsmile@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
166
src/main/utils/ipcManager.ts
Normal file
166
src/main/utils/ipcManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user