From 1c77773ad9bd24eac1cb3fca2f1e09b3efa9542c Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Tue, 7 Apr 2026 15:33:30 +0100 Subject: [PATCH] Add memory limits to image NSCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets a 256MB total cost limit and a 64MB per-object limit on the image cache. Cache costs are now calculated from the decoded RGBA pixel size (width × height × 4 bytes) rather than the compressed download size, which could underestimate real memory usage by 20-50× for typical JPEGs. Images larger than 64MB decoded are not cached — they still load and display normally but won't evict smaller cached images. All insertions are routed through a new setCachedImage helper so cost calculation and the per-object guard are enforced in one place. Closes #94 --- Mactrix/Models/MatrixClient.swift | 22 +++++++++++++++++-- Mactrix/Views/ChatView/MessageImageView.swift | 2 +- Mactrix/Views/MatrixImageView.swift | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index e99c7fb..ad417a6 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -240,7 +240,25 @@ extension MatrixClient: MatrixRustSDK.ClientSessionDelegate { } extension MatrixClient: UI.ImageLoader { - static let imageCache = NSCache() + // In-memory cache of decoded NSImage objects. Purpose is rendering performance — + // keeping decoded bitmaps ready to display prevents flicker when scrolling back to + // previously viewed images. This is distinct from the SDK-level media download cache + // (see clearCaches / getMediaFile(useCache:)) which avoids re-fetching from the server. + // Costs are tracked in decoded RGBA bytes (width × height × 4), not compressed sizes, + // since a typical JPEG can be 20-50× larger once decoded. + static let imageCache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = 256 * 1024 * 1024 // 256MB decoded pixels + return cache + }() + + private static let imageCacheMaxObjectCost = 64 * 1024 * 1024 // 64MB per object (~8000x2000px RGBA) + + static func setCachedImage(_ image: NSImage, forKey key: NSString) { + let cost = Int(image.size.width * image.size.height) * 4 // decoded RGBA bytes + guard cost <= imageCacheMaxObjectCost else { return } + imageCache.setObject(image, forKey: key, cost: cost) + } func cachedImage(matrixUrl: String) -> Image? { guard let nsImage = Self.imageCache.object(forKey: NSString(string: matrixUrl)) else { return nil } @@ -270,7 +288,7 @@ extension MatrixClient: UI.ImageLoader { do { let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) - Self.imageCache.setObject(nsImage, forKey: cacheKey, cost: imageData.count) + Self.setCachedImage(nsImage, forKey: cacheKey) return Image(nsImage: nsImage) } catch { Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift index 470c502..4519143 100644 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ b/Mactrix/Views/ChatView/MessageImageView.swift @@ -102,7 +102,7 @@ struct MessageImageView: View { let data = try await matrixClient.client.getMediaContent(mediaSource: content.source) imageData = data let nsImage = try data.toOrientedImage(contentType: contentType) - MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) + MatrixClient.setCachedImage(nsImage, forKey: cacheKey) image = Image(nsImage: nsImage) } catch { errorMessage = error.localizedDescription diff --git a/Mactrix/Views/MatrixImageView.swift b/Mactrix/Views/MatrixImageView.swift index c983f8a..87e3661 100644 --- a/Mactrix/Views/MatrixImageView.swift +++ b/Mactrix/Views/MatrixImageView.swift @@ -60,7 +60,7 @@ struct MatrixImageView: View { let contentType = mimeType.flatMap { UTType(mimeType: $0) } image = try await Image(importing: data, contentType: contentType) if let nsImage = NSImage(data: data) { - MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey, cost: data.count) + MatrixClient.setCachedImage(nsImage, forKey: cacheKey) } } catch { errorMessage = error.localizedDescription