Skip to content

Commit 5f9e850

Browse files
committed
Release v3.12.1
Fix memory leak from thumbnail loading - added global LRU cache with request deduplication
1 parent 781a08e commit 5f9e850

File tree

9 files changed

+259
-39
lines changed

9 files changed

+259
-39
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to BluePLM will be documented in this file.
44

5+
## [3.12.1] - 2026-01-26
6+
7+
### Fixed
8+
- **Memory leak from thumbnail loading**: Fixed severe memory leak where navigating between folders would repeatedly request thumbnails for the same files, causing RAM usage to grow unbounded (8GB+ observed). Added a global LRU thumbnail cache with 200 entry limit (~6MB max), request deduplication for concurrent loads, and 5-minute TTL. Same file previously requested 100+ times now makes a single IPC call. Cache is automatically invalidated when files are moved, renamed, or deleted
9+
10+
---
11+
512
## [3.12.0] - 2026-01-23
613

714
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "blue-plm",
3-
"version": "3.12.0",
3+
"version": "3.12.1",
44
"description": "Open-source Product Lifecycle Management",
55
"main": "dist-electron/main.js",
66
"scripts": {

src/components/layout/RightPanel/RightPanel.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useCallback } from 'react'
22
import { usePDMStore, LocalFile, DetailsPanelTab } from '@/stores/pdmStore'
3+
import { thumbnailCache } from '@/lib/thumbnailCache'
34
import { getFileIconType } from '@/lib/utils'
45
import { formatFileSize } from '@/lib/utils'
56
import { DraggableTab, TabDropZone, PanelLocation } from '@/components/shared/DraggableTab'
@@ -30,9 +31,10 @@ function RightPanelIcon({ file, size = 24 }: { file: LocalFile; size?: number })
3031

3132
const loadIcon = async () => {
3233
try {
33-
const result = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
34-
if (!cancelled && result?.success && result.data) {
35-
setIcon(result.data)
34+
// Use global thumbnail cache to avoid repeated IPC calls
35+
const data = await thumbnailCache.get(file.path)
36+
if (!cancelled && data) {
37+
setIcon(data)
3638
}
3739
} catch {
3840
// Silently fail
@@ -187,10 +189,10 @@ export function RightPanel() {
187189
return
188190
}
189191

190-
// Fall back to OS thumbnail
191-
const thumbResult = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
192-
if (thumbResult?.success && thumbResult.data) {
193-
setCadPreview(thumbResult.data)
192+
// Fall back to OS thumbnail (uses cache)
193+
const thumbData = await thumbnailCache.get(file.path)
194+
if (thumbData) {
195+
setCadPreview(thumbData)
194196
} else {
195197
setCadPreview(null)
196198
}

src/components/shared/FileItem/FileItemComponents.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from 'lucide-react'
2525
import { LocalFile } from '@/stores/pdmStore'
2626
import { getFileIconType, getInitials, getAvatarColor } from '@/lib/utils'
27+
import { thumbnailCache } from '@/lib/thumbnailCache'
2728

2829
// ============================================================================
2930
// FILE ICON - Loads OS thumbnail with fallback to type-based icons
@@ -55,9 +56,10 @@ export const FileIcon = memo(function FileIcon({ file, size = 16, className = ''
5556

5657
const loadIcon = async () => {
5758
try {
58-
const result = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
59-
if (!cancelled && result?.success && result.data) {
60-
setIcon(result.data)
59+
// Use global thumbnail cache to avoid repeated IPC calls
60+
const data = await thumbnailCache.get(file.path)
61+
if (!cancelled && data) {
62+
setIcon(data)
6163
}
6264
} catch {
6365
// Silently fail - will show default icon

src/features/integrations/solidworks/SWDatacardPanel.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect } from 'react'
22
import { log } from '@/lib/logger'
33
import { usePDMStore, LocalFile } from '@/stores/pdmStore'
4+
import { thumbnailCache } from '@/lib/thumbnailCache'
45
import {
56
FileBox,
67
Layers,
@@ -108,11 +109,11 @@ export function SWDatacardPanel({ file }: { file: LocalFile }) {
108109
return
109110
}
110111

111-
// OLE failed - Fall back to OS thumbnail as immediate fallback
112-
const thumbResult = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
113-
if (thumbResult?.success && thumbResult.data) {
112+
// OLE failed - Fall back to OS thumbnail as immediate fallback (uses cache)
113+
const thumbData = await thumbnailCache.get(file.path)
114+
if (thumbData) {
114115
log.debug('[Preview]', 'Using OS thumbnail fallback')
115-
setPreview(thumbResult.data)
116+
setPreview(thumbData)
116117
}
117118
} catch (err) {
118119
log.error('[Preview]', 'Failed to load OLE preview', { error: err })
@@ -178,9 +179,10 @@ export function SWDatacardPanel({ file }: { file: LocalFile }) {
178179
}
179180
}
180181

181-
const thumbResult = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
182-
if (thumbResult?.success && thumbResult.data) {
183-
setPreview(thumbResult.data)
182+
// Fall back to OS thumbnail (uses cache)
183+
const thumbData = await thumbnailCache.get(file.path)
184+
if (thumbData) {
185+
setPreview(thumbData)
184186
}
185187
} catch {
186188
// Silent fail

src/features/source/browser/components/FileGrid/hooks/useThumbnail.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from 'react'
2-
import { log } from '@/lib/logger'
3-
import { SW_THUMBNAIL_EXTENSIONS, MAX_THUMBNAIL_SIZE } from '../../../constants'
2+
import { thumbnailCache } from '@/lib/thumbnailCache'
3+
import { SW_THUMBNAIL_EXTENSIONS } from '../../../constants'
44

55
export interface UseThumbnailParams {
66
file: {
@@ -41,18 +41,10 @@ export function useThumbnail({ file, iconSize, isProcessing }: UseThumbnailParam
4141
setLoadingThumbnail(true)
4242
setThumbnailError(false)
4343
try {
44-
const result = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
45-
if (result?.success && result.data && result.data.startsWith('data:image/')) {
46-
if (result.data.length > 100 && result.data.length < MAX_THUMBNAIL_SIZE) {
47-
setThumbnail(result.data)
48-
} else {
49-
setThumbnail(null)
50-
}
51-
} else {
52-
setThumbnail(null)
53-
}
54-
} catch (err) {
55-
log.error('[Thumbnail]', 'Failed to extract thumbnail', { error: err })
44+
// Use global thumbnail cache to avoid repeated IPC calls
45+
const data = await thumbnailCache.get(file.path)
46+
setThumbnail(data)
47+
} catch {
5648
setThumbnail(null)
5749
} finally {
5850
setLoadingThumbnail(false)

src/features/source/details/DetailsPanel.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useCallback } from 'react'
22
import { log } from '@/lib/logger'
33
import { usePDMStore, LocalFile, DetailsPanelTab } from '@/stores/pdmStore'
4+
import { thumbnailCache } from '@/lib/thumbnailCache'
45
import { getFileIconType } from '@/lib/utils'
56
import { formatFileSize } from '@/lib/utils'
67
import { DraggableTab, TabDropZone, PanelLocation } from '@/components/shared/DraggableTab'
@@ -54,9 +55,10 @@ function DetailsPanelIcon({ file, size = 32 }: { file: LocalFile; size?: number
5455

5556
const loadIcon = async () => {
5657
try {
57-
const result = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
58-
if (!cancelled && result?.success && result.data) {
59-
setIcon(result.data)
58+
// Use global thumbnail cache to avoid repeated IPC calls
59+
const data = await thumbnailCache.get(file.path)
60+
if (!cancelled && data) {
61+
setIcon(data)
6062
}
6163
} catch {
6264
// Silently fail
@@ -266,11 +268,11 @@ export function DetailsPanel() {
266268
return
267269
}
268270

269-
// Fall back to OS thumbnail extraction
270-
const thumbResult = await window.electronAPI?.extractSolidWorksThumbnail(file.path)
271-
if (thumbResult?.success && thumbResult.data) {
271+
// Fall back to OS thumbnail extraction (uses cache)
272+
const thumbData = await thumbnailCache.get(file.path)
273+
if (thumbData) {
272274
log.debug('[Preview]', 'Using OS thumbnail fallback')
273-
setCadThumbnail(thumbResult.data)
275+
setCadThumbnail(thumbData)
274276
} else {
275277
setCadThumbnail(null)
276278
}

src/lib/thumbnailCache.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Global LRU thumbnail cache to prevent repeated IPC calls and memory leaks.
3+
*
4+
* Features:
5+
* - LRU eviction when cache exceeds MAX_CACHE_SIZE
6+
* - Request deduplication via pending promises
7+
* - TTL expiration to handle file updates
8+
* - Invalidation API for file operations (move, rename, delete)
9+
*/
10+
11+
import { log } from './logger'
12+
13+
interface CacheEntry {
14+
data: string // base64 data URL
15+
timestamp: number // for TTL expiration
16+
accessTime: number // for LRU eviction
17+
}
18+
19+
// Cache configuration
20+
const MAX_CACHE_SIZE = 200 // ~6MB max (200 x 30KB avg)
21+
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
22+
23+
// Global cache state (module singleton)
24+
const cache = new Map<string, CacheEntry>()
25+
const pending = new Map<string, Promise<string | null>>()
26+
27+
// Normalize path for cache key (lowercase, forward slashes)
28+
function normalizePath(filePath: string): string {
29+
return filePath.replace(/\\/g, '/').toLowerCase()
30+
}
31+
32+
/**
33+
* Evict oldest entries when cache exceeds max size
34+
*/
35+
function evictOldest(): void {
36+
if (cache.size <= MAX_CACHE_SIZE) return
37+
38+
// Sort entries by access time (oldest first)
39+
const entries = Array.from(cache.entries())
40+
.sort((a, b) => a[1].accessTime - b[1].accessTime)
41+
42+
// Remove oldest 20% to avoid frequent evictions
43+
const toRemove = Math.ceil(cache.size * 0.2)
44+
for (let i = 0; i < toRemove && i < entries.length; i++) {
45+
cache.delete(entries[i][0])
46+
}
47+
48+
log.debug('[ThumbnailCache]', `Evicted ${toRemove} oldest entries`, {
49+
newSize: cache.size
50+
})
51+
}
52+
53+
/**
54+
* Check if entry is expired
55+
*/
56+
function isExpired(entry: CacheEntry): boolean {
57+
return Date.now() - entry.timestamp > CACHE_TTL_MS
58+
}
59+
60+
/**
61+
* Get thumbnail from cache or fetch via IPC.
62+
* Deduplicates concurrent requests for the same file.
63+
*
64+
* @param filePath - Full file path
65+
* @returns Base64 data URL or null if not available
66+
*/
67+
export async function getThumbnail(filePath: string): Promise<string | null> {
68+
const key = normalizePath(filePath)
69+
70+
// Check cache first
71+
const cached = cache.get(key)
72+
if (cached && !isExpired(cached)) {
73+
// Update access time for LRU
74+
cached.accessTime = Date.now()
75+
return cached.data
76+
}
77+
78+
// Remove expired entry if exists
79+
if (cached) {
80+
cache.delete(key)
81+
}
82+
83+
// Check if request is already pending
84+
const pendingRequest = pending.get(key)
85+
if (pendingRequest) {
86+
return pendingRequest
87+
}
88+
89+
// Create new request
90+
const request = fetchThumbnail(filePath, key)
91+
pending.set(key, request)
92+
93+
try {
94+
const result = await request
95+
return result
96+
} finally {
97+
pending.delete(key)
98+
}
99+
}
100+
101+
/**
102+
* Fetch thumbnail from electron IPC
103+
*/
104+
async function fetchThumbnail(filePath: string, key: string): Promise<string | null> {
105+
try {
106+
const result = await window.electronAPI?.extractSolidWorksThumbnail(filePath)
107+
108+
if (result?.success && result.data && result.data.startsWith('data:image/')) {
109+
// Validate data size (skip if too small or too large)
110+
if (result.data.length > 100 && result.data.length < 10000000) {
111+
const now = Date.now()
112+
cache.set(key, {
113+
data: result.data,
114+
timestamp: now,
115+
accessTime: now
116+
})
117+
118+
// Evict if needed
119+
evictOldest()
120+
121+
return result.data
122+
}
123+
}
124+
125+
return null
126+
} catch (err) {
127+
log.error('[ThumbnailCache]', 'Failed to fetch thumbnail', {
128+
path: filePath,
129+
error: err
130+
})
131+
return null
132+
}
133+
}
134+
135+
/**
136+
* Invalidate cache entry for a specific path.
137+
* Call this when files are moved, renamed, or deleted.
138+
*
139+
* @param filePath - File path to invalidate
140+
*/
141+
export function invalidate(filePath: string): void {
142+
const key = normalizePath(filePath)
143+
if (cache.delete(key)) {
144+
log.debug('[ThumbnailCache]', 'Invalidated', { path: filePath })
145+
}
146+
}
147+
148+
/**
149+
* Invalidate all cache entries under a folder path.
150+
* Call this when folders are moved, renamed, or deleted.
151+
*
152+
* @param folderPath - Folder path prefix to invalidate
153+
*/
154+
export function invalidateFolder(folderPath: string): void {
155+
const prefix = normalizePath(folderPath)
156+
let count = 0
157+
158+
for (const key of cache.keys()) {
159+
if (key.startsWith(prefix + '/') || key === prefix) {
160+
cache.delete(key)
161+
count++
162+
}
163+
}
164+
165+
if (count > 0) {
166+
log.debug('[ThumbnailCache]', `Invalidated folder`, {
167+
path: folderPath,
168+
count
169+
})
170+
}
171+
}
172+
173+
/**
174+
* Clear the entire cache.
175+
* Call this on sign-out or vault change.
176+
*/
177+
export function clearCache(): void {
178+
const size = cache.size
179+
cache.clear()
180+
pending.clear()
181+
if (size > 0) {
182+
log.debug('[ThumbnailCache]', `Cleared cache`, { entriesCleared: size })
183+
}
184+
}
185+
186+
/**
187+
* Get current cache statistics (for debugging)
188+
*/
189+
export function getCacheStats(): { size: number; pendingCount: number } {
190+
return {
191+
size: cache.size,
192+
pendingCount: pending.size
193+
}
194+
}
195+
196+
// Export as namespace for cleaner imports
197+
export const thumbnailCache = {
198+
get: getThumbnail,
199+
invalidate,
200+
invalidateFolder,
201+
clear: clearCache,
202+
getStats: getCacheStats
203+
}

0 commit comments

Comments
 (0)