From 9754881af83ff9fee5aaee087442d63dd1540353 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 30 Sep 2025 14:27:50 +0200 Subject: [PATCH] Fix memory leaks and post-playback freeze Fixes: 1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main thread before updating @Published properties (Swift concurrency violation) 2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit - Track last access time for each cached waveform - Evict oldest entry when cache is full - Prevents unlimited memory growth as voice notes accumulate 3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool - AVAudioPCMBuffer allocations are autoreleased - Pool ensures buffers are freed promptly 4. Image processing memory: Add autoreleasepool around compression loops - Each jpegData() call creates temporary objects - Inner pool per iteration prevents memory spikes during quality search Memory should now remain stable during extended use. --- bitchat/Features/media/ImageUtils.swift | 100 ++++++++-------- .../voice/VoiceNotePlaybackController.swift | 12 +- bitchat/Features/voice/Waveform.swift | 109 +++++++++++------- 3 files changed, 128 insertions(+), 93 deletions(-) diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index 2d375699..d4784e48 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -29,19 +29,24 @@ enum ImageUtils { #if os(iOS) static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL { - let scaled = scaledImage(image, maxDimension: maxDimension) - var quality = compressionQuality - guard var jpegData = scaled.jpegData(compressionQuality: quality) else { - throw ImageUtilsError.encodingFailed + return try autoreleasepool { + let scaled = scaledImage(image, maxDimension: maxDimension) + var quality = compressionQuality + guard var jpegData = scaled.jpegData(compressionQuality: quality) else { + throw ImageUtilsError.encodingFailed + } + while jpegData.count > targetImageBytes && quality > 0.3 { + quality -= 0.1 + autoreleasepool { + if let next = scaled.jpegData(compressionQuality: quality) { + jpegData = next + } + } + } + let outputURL = try makeOutputURL() + try jpegData.write(to: outputURL, options: .atomic) + return outputURL } - while jpegData.count > targetImageBytes && quality > 0.3 { - quality -= 0.1 - guard let next = scaled.jpegData(compressionQuality: quality) else { break } - jpegData = next - } - let outputURL = try makeOutputURL() - try jpegData.write(to: outputURL, options: .atomic) - return outputURL } private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { @@ -58,40 +63,45 @@ enum ImageUtils { } #else static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL { - let scaled = scaledImage(image, maxDimension: maxDimension) - guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - throw ImageUtilsError.encodingFailed + return try autoreleasepool { + let scaled = scaledImage(image, maxDimension: maxDimension) + guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + throw ImageUtilsError.encodingFailed + } + let width = inputCG.width + let height = inputCG.height + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw ImageUtilsError.encodingFailed + } + context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let cgImage = context.makeImage() else { + throw ImageUtilsError.encodingFailed + } + var quality = compressionQuality + guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else { + throw ImageUtilsError.encodingFailed + } + while jpegData.count > targetImageBytes && quality > 0.3 { + quality -= 0.1 + autoreleasepool { + if let next = encodeJPEG(from: cgImage, quality: quality) { + jpegData = next + } + } + } + let outputURL = try makeOutputURL() + try jpegData.write(to: outputURL, options: .atomic) + return outputURL } - let width = inputCG.width - let height = inputCG.height - let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw ImageUtilsError.encodingFailed - } - context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height)) - guard let cgImage = context.makeImage() else { - throw ImageUtilsError.encodingFailed - } - var quality = compressionQuality - guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else { - throw ImageUtilsError.encodingFailed - } - while jpegData.count > targetImageBytes && quality > 0.3 { - quality -= 0.1 - guard let next = encodeJPEG(from: cgImage, quality: quality) else { break } - jpegData = next - } - let outputURL = try makeOutputURL() - try jpegData.write(to: outputURL, options: .atomic) - return outputURL } private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index a375cd02..de475e51 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -91,10 +91,14 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - stopTimer() - updateProgress() - isPlaying = false - VoiceNotePlaybackCoordinator.shared.deactivate(self) + // Delegate callback may be on background thread - ensure main thread for UI updates + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.stopTimer() + self.updateProgress() + self.isPlaying = false + VoiceNotePlaybackCoordinator.shared.deactivate(self) + } } // MARK: - Private Helpers diff --git a/bitchat/Features/voice/Waveform.swift b/bitchat/Features/voice/Waveform.swift index bac92114..0718db3c 100644 --- a/bitchat/Features/voice/Waveform.swift +++ b/bitchat/Features/voice/Waveform.swift @@ -7,19 +7,28 @@ final class WaveformCache { static let shared = WaveformCache() private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent) - private var cache: [URL: [Float]] = [:] + private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:] + private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth private init() {} func cachedWaveform(for url: URL) -> [Float]? { - queue.sync { cache[url] } + queue.sync { + guard let entry = cache[url] else { return nil } + return entry.waveform + } } func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) { queue.async { [weak self] in guard let self = self else { return } - if let cached = self.cache[url] { - DispatchQueue.main.async { completion(cached) } + + // Check cache and update access time + if let entry = self.cache[url] { + self.queue.async(flags: .barrier) { [weak self] in + self?.cache[url] = (entry.waveform, Date()) + } + DispatchQueue.main.async { completion(entry.waveform) } return } @@ -28,8 +37,17 @@ final class WaveformCache { return } - self.queue.async(flags: .barrier) { - self.cache[url] = computed + self.queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + // Evict oldest entry if cache is full + if self.cache.count >= self.maxCacheSize { + if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) { + self.cache.removeValue(forKey: oldest.key) + } + } + + self.cache[url] = (computed, Date()) } DispatchQueue.main.async { completion(computed) } } @@ -49,47 +67,50 @@ final class WaveformCache { private func computeWaveform(url: URL, bins: Int) -> [Float]? { guard bins > 0 else { return nil } - do { - let audioFile = try AVAudioFile(forReading: url) - let length = Int(audioFile.length) - guard length > 0 else { return nil } + // Use autoreleasepool to manage memory from audio buffer allocations + return autoreleasepool { + do { + let audioFile = try AVAudioFile(forReading: url) + let length = Int(audioFile.length) + guard length > 0 else { return nil } - guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else { + guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else { + return nil + } + try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length)) + guard let channelData = buffer.floatChannelData else { return nil } + + let channelCount = Int(audioFile.processingFormat.channelCount) + let frameLength = Int(buffer.frameLength) + let samplesPerBin = max(1, frameLength / bins) + + var magnitudes: [Float] = Array(repeating: 0, count: bins) + for bin in 0..= end { break } + + var sum: Float = 0 + var sampleCount = 0 + for frame in start.. 0 ? sum / Float(sampleCount) : 0 + } + + if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 { + magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) } + } + return magnitudes + } catch { + SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session) return nil } - try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length)) - guard let channelData = buffer.floatChannelData else { return nil } - - let channelCount = Int(audioFile.processingFormat.channelCount) - let frameLength = Int(buffer.frameLength) - let samplesPerBin = max(1, frameLength / bins) - - var magnitudes: [Float] = Array(repeating: 0, count: bins) - for bin in 0..= end { break } - - var sum: Float = 0 - var sampleCount = 0 - for frame in start.. 0 ? sum / Float(sampleCount) : 0 - } - - if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 { - magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) } - } - return magnitudes - } catch { - SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session) - return nil } } }