--- name: axiom-memory-debugging description: Use when you see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so much memory', 'how do I find memory leaks', 'my deinit is never called', 'Instruments shows memory growth', 'app crashes after 10 minutes' - systematic memory leak detection and fixes for iOS/macOS skill_type: discipline version: 1.0.0 # MCP annotations (ignored by Claude Code) mcp: category: debugging tags: [memory, leaks, instruments, retain-cycles, performance, allocations] related: [performance-profiling, axiom-objc-block-retain-cycles] --- # Memory Debugging ## Overview Memory issues manifest as crashes after prolonged use. **Core principle** 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess. ## Example Prompts These are real questions developers ask that this skill is designed to answer: #### 1. "My app crashes after 10-15 minutes of use, but there are no error messages. How do I figure out what's leaking?" → The skill covers systematic Instruments workflows to identify memory leaks vs normal memory pressure, with real diagnostic patterns #### 2. "I'm seeing memory jump from 50MB to 200MB+ when I perform a specific action. Is this a leak or normal caching behavior?" → The skill distinguishes between progressive leaks (continuous growth) and temporary spikes (caches that stabilize), with diagnostic criteria #### 3. "View controllers don't seem to be deallocating after I dismiss them. How do I find the retain cycle causing this?" → The skill demonstrates Memory Graph Debugger techniques to identify objects holding strong references and common retain cycle patterns #### 4. "I have timers/observers in my code and I think they're causing memory leaks. How do I verify and fix this?" → The skill covers the 5 diagnostic patterns, including specific timer and observer leak signatures with prevention strategies #### 5. "My app uses 200MB of memory and I don't know if that's normal or if I have multiple leaks. How do I diagnose?" → The skill provides the Instruments decision tree to distinguish normal memory use, expected caches, and actual leaks requiring fixes --- ## Red Flags — Memory Leak Likely If you see ANY of these, suspect memory leak not just heavy memory use: - Progressive memory growth: 50MB → 100MB → 200MB (not plateauing) - App crashes after 10-15 minutes with no error in Xcode console - Memory warnings appear repeatedly in device logs - Specific screen/operation makes memory jump (10-50MB spike) - View controllers don't deallocate after dismiss (visible in Memory Graph Debugger) - Same operation run multiple times causes linear memory growth #### Difference from normal memory use - Normal: App uses 100MB, stays at 100MB (memory pressure handled by iOS) - Leak: App uses 50MB, becomes 100MB, 150MB, 200MB → CRASH ## Mandatory First Steps **ALWAYS run these commands/checks FIRST** (before reading code): ```bash # 1. Check device logs for memory warnings # Connect device, open Xcode Console (Cmd+Shift+2) # Trigger the crash scenario # Look for: "Memory pressure critical", "Jetsam killed", "Low Memory" # 2. Check which objects are leaking # Use Memory Graph Debugger (below) — shows object count growth # 3. Check instruments baseline # Xcode → Product → Profile → Memory # Run for 1 minute, note baseline # Perform operation 5 times, note if memory keeps growing ``` #### What this tells you - **Memory stays flat** → Likely not a leak, check memory pressure handling - **Memory grows linearly** → Classic leak (timer, observer, closure capture) - **Sudden spikes then flattens** → Probably normal (caches, lazy loading) - **Spikes AND keeps growing** → Compound leak (multiple leaks stacking) #### Why diagnostics first - Finding leak with Instruments: 5-15 minutes - Guessing and testing fixes: 45+ minutes ## Quick Decision Tree ``` Memory growing? ├─ Progressive growth every minute? │ └─ Likely retain cycle or timer leak ├─ Spike when action performed? │ └─ Check if operation runs multiple times ├─ Spike then flat for 30 seconds? │ └─ Probably normal (collections, caches) ├─ Multiple large spikes stacking? │ └─ Compound leak (multiple sources) └─ Can't tell from visual inspection? └─ Use Instruments Memory Graph (see below) ``` ## Detecting Leaks — Step by Step ### Step 1: Memory Graph Debugger (Fastest Leak Detection) ``` 1. Open your app in Xcode simulator 2. Click: Debug → Memory Graph Debugger (or icon in top toolbar) 3. Wait for graph to generate (5-10 seconds) 4. Look for PURPLE/RED circles with "⚠" badge 5. Click them → Xcode shows retain cycle chain ``` #### What you're looking for ``` ✅ Object appears once ❌ Object appears 2+ times (means it's retained multiple times) ``` #### Example output (indicates leak) ``` PlayerViewModel ↑ strongRef from: progressTimer ↑ strongRef from: TimerClosure [weak self] captured self ↑ CYCLE DETECTED: This creates a retain cycle! ``` ### Step 2: Instruments (Detailed Memory Analysis) ``` 1. Product → Profile (Cmd+I) 2. Select "Memory" template 3. Run scenario that causes memory growth 4. Perform action 5-10 times 5. Check: Does memory line go UP for each action? - YES → Leak confirmed - NO → Probably not a leak ``` #### Key instruments to check - **Heap Allocations**: Shows object count - **Leaked Objects**: Direct leak detection - **VM Tracker**: Shows memory by type - **System Memory**: Shows OS pressure #### How to read the graph ``` Time ──→ Memory │ ▗━━━━━━━━━━━━━━━━ ← Memory keeps growing (LEAK) │ ▄▀ │ ▄▀ │ ▄ └───────────────────── Action 1 2 3 4 5 vs normal pattern: Time ──→ Memory │ ▗━━━━━━━━━━━━━━━━━━ ← Memory plateaus (OK) │ ▄▀ │▄ └───────────────────── Action 1 2 3 4 5 ``` ### Step 3: View Controller Memory Check For SwiftUI or UIKit view controllers: ```swift // SwiftUI: Check if view disappears cleanly @main struct DebugApp: App { init() { NotificationCenter.default.addObserver( forName: NSNotification.Name("UIViewControllerWillDeallocate"), object: nil, queue: .main ) { _ in print("✅ ViewController deallocated") } } var body: some Scene { ... } } // UIKit: Add deinit logging class MyViewController: UIViewController { deinit { print("✅ MyViewController deallocated") } } // SwiftUI: Use deinit in view models @MainActor class ViewModel: ObservableObject { deinit { print("✅ ViewModel deallocated") } } ``` #### Test procedure ``` 1. Add deinit logging above 2. Launch app in Xcode 3. Navigate to view/create ViewModel 4. Navigate away/dismiss 5. Check Console: Do you see "✅ deallocated"? - YES → No leak there - NO → Object is retained somewhere ``` ## Common Memory Leak Patterns (With Fixes) ### Pattern 1: Timer Leaks (Most Common) #### ❌ Leak — Timer retains closure, closure retains self ```swift @MainActor class PlayerViewModel: ObservableObject { @Published var currentTrack: Track? private var progressTimer: Timer? func startPlayback(_ track: Track) { currentTrack = track // LEAK: Timer.scheduledTimer captures 'self' in closure // Even with [weak self], the Timer itself is strong progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateProgress() } // Timer is never stopped → keeps firing forever } // Missing: Timer never invalidated deinit { // LEAK: If timer still running, deinit never called } } ``` #### Leak mechanism ``` ViewController → strongly retains ViewModel ↓ ViewModel → strongly retains Timer ↓ Timer → strongly retains closure ↓ Closure → captures [weak self] but still holds reference to Timer ``` #### Closure captures `self` weakly BUT - Timer is still strong reference in ViewModel - Timer is still running (repeats: true) - Even with [weak self], timer closure doesn't go away #### ✅ Fix 1: Invalidate on deinit ```swift @MainActor class PlayerViewModel: ObservableObject { @Published var currentTrack: Track? private var progressTimer: Timer? func startPlayback(_ track: Track) { currentTrack = track progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateProgress() } } func stopPlayback() { progressTimer?.invalidate() progressTimer = nil // Important: nil after invalidate currentTrack = nil } deinit { progressTimer?.invalidate() // ← CRITICAL FIX progressTimer = nil } } ``` #### ✅ Fix 2: Use AnyCancellable (Modern approach) ```swift @MainActor class PlayerViewModel: ObservableObject { @Published var currentTrack: Track? private var cancellable: AnyCancellable? func startPlayback(_ track: Track) { currentTrack = track // Timer with Combine - auto-cancels when cancellable is released cancellable = Timer.publish( every: 1.0, tolerance: 0.1, on: .main, in: .default ) .autoconnect() .sink { [weak self] _ in self?.updateProgress() } } func stopPlayback() { cancellable?.cancel() // Auto-cleans up cancellable = nil currentTrack = nil } // No need for deinit — Combine handles cleanup } ``` #### ✅ Fix 3: Weak self + nil check (Emergency fix) ```swift @MainActor class PlayerViewModel: ObservableObject { @Published var currentTrack: Track? private var progressTimer: Timer? func startPlayback(_ track: Track) { currentTrack = track // If progressTimer already exists, stop it first progressTimer?.invalidate() progressTimer = nil progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { // If self deallocated, timer still fires but does nothing // Still not ideal - timer keeps consuming CPU return } self.updateProgress() } } func stopPlayback() { progressTimer?.invalidate() progressTimer = nil } deinit { progressTimer?.invalidate() progressTimer = nil } } ``` #### Why the fixes work - `invalidate()`: Stops timer immediately, breaks retain cycle - `cancellable`: Automatically invalidates when released - `[weak self]`: If ViewModel released before timer, timer becomes no-op - `deinit cleanup`: Ensures timer always cleaned up #### Test the fix ```swift func testPlayerViewModelNotLeaked() { var viewModel: PlayerViewModel? = PlayerViewModel() let track = Track(id: "1", title: "Song") viewModel?.startPlayback(track) // Verify timer running XCTAssertNotNil(viewModel?.progressTimer) // Stop and deallocate viewModel?.stopPlayback() viewModel = nil // ✅ Should deallocate without leak warning } ``` ### Pattern 2: Observer/Notification Leaks #### ❌ Leak — Observer holds strong reference to self ```swift @MainActor class PlayerViewModel: ObservableObject { init() { // LEAK: addObserver keeps strong reference to self NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionChange), name: AVAudioSession.routeChangeNotification, object: nil ) // No matching removeObserver → accumulates listeners } @objc private func handleAudioSessionChange() { } deinit { // Missing: Never unregistered } } ``` #### ✅ Fix 1: Manual cleanup in deinit ```swift @MainActor class PlayerViewModel: ObservableObject { init() { NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionChange), name: AVAudioSession.routeChangeNotification, object: nil ) } @objc private func handleAudioSessionChange() { } deinit { NotificationCenter.default.removeObserver(self) // ← FIX } } ``` #### ✅ Fix 2: Use modern Combine approach (Best practice) ```swift @MainActor class PlayerViewModel: ObservableObject { private var cancellables = Set() init() { NotificationCenter.default.publisher( for: AVAudioSession.routeChangeNotification ) .sink { [weak self] _ in self?.handleAudioSessionChange() } .store(in: &cancellables) // Auto-cleanup with viewModel } private func handleAudioSessionChange() { } // No deinit needed - cancellables auto-cleanup } ``` #### ✅ Fix 3: Use @Published with map (Reactive) ```swift @MainActor class PlayerViewModel: ObservableObject { @Published var currentRoute: AVAudioSession.AudioSessionRouteDescription? private var cancellables = Set() init() { NotificationCenter.default.publisher( for: AVAudioSession.routeChangeNotification ) .map { _ in AVAudioSession.sharedInstance().currentRoute } .assign(to: &$currentRoute) // Auto-cleanup with publisher chain } } ``` ### Pattern 3: Closure Capture Leaks (Collection/Array) #### ❌ Leak — Closure captured in array, captures self ```swift @MainActor class PlaylistViewController: UIViewController { private var tracks: [Track] = [] private var updateCallbacks: [(Track) -> Void] = [] // LEAK SOURCE func addUpdateCallback() { // LEAK: Closure captures 'self' updateCallbacks.append { [self] track in self.refreshUI(with: track) // Strong capture of self } // updateCallbacks grows and never cleared } // No mechanism to clear callbacks deinit { // updateCallbacks still references self } } ``` #### Leak mechanism ``` ViewController ↓ strongly owns updateCallbacks array ↓ contains Closure captures self ↓ CYCLE Back to ViewController (can't deallocate) ``` #### ✅ Fix 1: Use weak self in closure ```swift @MainActor class PlaylistViewController: UIViewController { private var tracks: [Track] = [] private var updateCallbacks: [(Track) -> Void] = [] func addUpdateCallback() { updateCallbacks.append { [weak self] track in self?.refreshUI(with: track) // Weak capture } } deinit { updateCallbacks.removeAll() // Clean up array } } ``` #### ✅ Fix 2: Use unowned (when you're certain self lives longer) ```swift @MainActor class PlaylistViewController: UIViewController { private var updateCallbacks: [(Track) -> Void] = [] func addUpdateCallback() { updateCallbacks.append { [unowned self] track in self.refreshUI(with: track) // Unowned is faster } // Use unowned ONLY if callback always destroyed before ViewController } deinit { updateCallbacks.removeAll() } } ``` #### ✅ Fix 3: Cancel callbacks when done (Reactive) ```swift @MainActor class PlaylistViewController: UIViewController { private var cancellables = Set() func addUpdateCallback(_ handler: @escaping (Track) -> Void) { // Use PassthroughSubject instead of array Just(()) .sink { [weak self] in handler(/* track */) } .store(in: &cancellables) } // When done: func clearCallbacks() { cancellables.removeAll() // Cancels all subscriptions } } ``` #### Test the fix ```swift func testCallbacksNotLeak() { var viewController: PlaylistViewController? = PlaylistViewController() viewController?.addUpdateCallback { _ in } // Verify callback registered XCTAssert(viewController?.updateCallbacks.count ?? 0 > 0) // Clear and deallocate viewController?.updateCallbacks.removeAll() viewController = nil // ✅ Should deallocate } ``` ### Pattern 4: Strong Reference Cycles (Closures + Properties) #### ❌ Leak — Two objects strongly reference each other ```swift @MainActor class Player: NSObject { var delegate: PlayerDelegate? // Strong reference var onPlaybackEnd: (() -> Void)? // ← Closure captures self init(delegate: PlayerDelegate) { self.delegate = delegate // LEAK CYCLE: // Player → (owns) → delegate // delegate → (through closure) → owns → Player } } class PlaylistController: PlayerDelegate { var player: Player? override init() { super.init() self.player = Player(delegate: self) // Self-reference cycle player?.onPlaybackEnd = { [self] in // LEAK: Closure captures self // self owns player // player owns delegate (self) // Cycle! self.playNextTrack() } } } ``` #### ✅ Fix: Break cycle with weak self ```swift @MainActor class PlaylistController: PlayerDelegate { var player: Player? override init() { super.init() self.player = Player(delegate: self) player?.onPlaybackEnd = { [weak self] in // Weak self breaks the cycle self?.playNextTrack() } } deinit { player?.onPlaybackEnd = nil // Optional cleanup player = nil } } ``` ### Pattern 5: View/Layout Callback Leaks #### ❌ Leak — View layout callback retains view controller ```swift @MainActor class DetailViewController: UIViewController { let customView = UIView() override func viewDidLoad() { super.viewDidLoad() // LEAK: layoutIfNeeded closure captures self customView.layoutIfNeeded = { [self] in // Every layout triggers this, keeping self alive self.updateLayout() } } } ``` #### ✅ Fix: Use @IBAction or proper delegation pattern ```swift @MainActor class DetailViewController: UIViewController { @IBOutlet weak var customView: CustomView! override func viewDidLoad() { super.viewDidLoad() customView.delegate = self // Weak reference through protocol } deinit { customView?.delegate = nil // Clean up } } protocol CustomViewDelegate: AnyObject { // AnyObject = weak by default func customViewDidLayout(_ view: CustomView) } ``` ### Pattern 6: PhotoKit Image Request Leaks #### ❌ Leak — PHImageManager requests accumulate without cancellation This pattern is specific to photo/media apps using PhotoKit or similar async image loading APIs. ```swift // LEAK: Image requests not cancelled when cells scroll away class PhotoViewController: UIViewController { let imageManager = PHImageManager.default() func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) let asset = photos[indexPath.item] // LEAK: Requests accumulate - never cancelled imageManager.requestImage( for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil ) { [weak self] image, _ in cell.imageView.image = image // Still called even if cell scrolled away } return cell } func scrollViewDidScroll(_ scrollView: UIScrollView) { // Each scroll triggers 50+ new image requests // Previous requests still pending, accumulating in queue } } ``` #### Symptoms - Memory jumps 50MB+ when scrolling long photo lists - Crashes happen after scrolling through 100+ photos - Specific operation causes leak (photo scrolling, not other screens) - Works fine locally with 10 photos, crashes on user devices with 1000+ photos **Root cause** `PHImageManager.requestImage()` returns a `PHImageRequestID` that must be explicitly cancelled. Without cancellation, pending requests queue up and hold memory. #### ✅ Fix: Store request ID and cancel in prepareForReuse() ```swift class PhotoCell: UICollectionViewCell { @IBOutlet weak var imageView: UIImageView! private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID func configure(with asset: PHAsset, imageManager: PHImageManager) { // Cancel previous request before starting new one if imageRequestID != PHInvalidImageRequestID { imageManager.cancelImageRequest(imageRequestID) } imageRequestID = imageManager.requestImage( for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFill, options: nil ) { [weak self] image, _ in self?.imageView.image = image } } override func prepareForReuse() { super.prepareForReuse() // CRITICAL: Cancel pending request when cell is reused if imageRequestID != PHInvalidImageRequestID { PHImageManager.default().cancelImageRequest(imageRequestID) imageRequestID = PHInvalidImageRequestID } imageView.image = nil // Clear stale image } deinit { // Safety check - shouldn't be needed if prepareForReuse called if imageRequestID != PHInvalidImageRequestID { PHImageManager.default().cancelImageRequest(imageRequestID) } } } // Controller class PhotoViewController: UIViewController, UICollectionViewDataSource { let imageManager = PHImageManager.default() func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoCell", for: indexPath) as! PhotoCell let asset = photos[indexPath.item] cell.configure(with: asset, imageManager: imageManager) return cell } } ``` #### Key points - Store `PHImageRequestID` in cell (not in view controller) - Cancel BEFORE starting new request (prevents request storms) - Cancel in `prepareForReuse()` (critical for collection views) - Check `imageRequestID != PHInvalidImageRequestID` before cancelling #### Other async APIs with similar patterns - `AVAssetImageGenerator.generateCGImagesAsynchronously()` → call `cancelAllCGImageGeneration()` - `URLSession.dataTask()` → call `cancel()` on task - Custom image caches → implement `invalidate()` or `cancel()` method ## Debugging Non-Reproducible Memory Issues **Challenge** Memory leak only happens with specific user data (large photo collections, complex data models) that you can't reproduce locally. ### Step 1: Enable Remote Memory Diagnostics Add MetricKit diagnostics to your app: ```swift import MetricKit class MemoryDiagnosticsManager { static let shared = MemoryDiagnosticsManager() private let metricManager = MXMetricManager.shared func startMonitoring() { metricManager.add(self) } } extension MemoryDiagnosticsManager: MXMetricManagerSubscriber { func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { if let memoryMetrics = payload.memoryMetrics { let peakMemory = memoryMetrics.peakMemoryUsage // Log if exceeding threshold if peakMemory > 400_000_000 { // 400MB print("⚠️ High memory: \(peakMemory / 1_000_000)MB") // Send to analytics } } } } } ``` ### Step 2: Ask Users for Device Logs When user reports crash: 1. iPhone → Settings → Privacy & Security → Analytics → Analytics Data 2. Look for latest crash log (named like `YourApp_2024-01-15-12-45-23`) 3. Email or upload to your support system 4. Xcode → Window → Devices & Simulators → select device → View Device Logs 5. Search for "Memory" or "Jetsam" in logs ### Step 3: TestFlight Beta Testing Before App Store release: ```swift #if DEBUG // Add to AppDelegate import os.log let logger = os.log(subsystem: "com.yourapp.memory", category: "lifecycle") // Log memory milestones func logMemory(_ event: String) { let memoryUsage = ProcessInfo.processInfo.physicalMemory / 1_000_000 os.log("🔍 [%s] Memory: %dMB", log: logger, type: .info, event, memoryUsage) } #endif ``` Send TestFlight build to affected users: 1. Build → Archive → Distribute App 2. Select TestFlight 3. Add affected user email 4. In TestFlight, ask user to: - Reproduce the crash scenario - Check if memory stabilizes (logs to system.log) - Report if crash still happens ### Step 4: Verify Fix Production Deployment After deploying fix: 1. Monitor MetricKit metrics for 24-48 hours 2. Check crash rate drop in App Analytics 3. If still seeing high memory users: - Add more diagnostic logging for next version - Consider lower memory device testing (iPad with constrained memory) ## Systematic Debugging Workflow ### Phase 1: Confirm Leak (5 minutes) ``` 1. Open app in simulator 2. Xcode → Product → Profile → Memory 3. Record baseline memory 4. Repeat action 10 times 5. Check memory graph: - Flat line = NOT a leak (stop here) - Steady climb = LEAK (go to Phase 2) ``` ### Phase 2: Locate Leak (10-15 minutes) ``` 1. Close Instruments 2. Xcode → Debug → Memory Graph Debugger 3. Wait for graph (5-10 sec) 4. Look for purple/red circles with ⚠ 5. Click on leaked object 6. Read the retain cycle chain: PlayerViewModel (leak) ↑ retained by progressTimer ↑ retained by TimerClosure ↑ retained by [self] capture ``` #### Common leak locations (in order of likelihood) - Timers (50% of leaks) - Notifications/KVO (25%) - Closures in arrays/collections (15%) - Delegate cycles (10%) ### Phase 3: Test Hypothesis (5 minutes) Apply fix from "Common Patterns" section above, then: ```swift // Add deinit logging class PlayerViewModel: ObservableObject { deinit { print("✅ PlayerViewModel deallocated - leak fixed!") } } ``` Run in Xcode, perform operation, check console for dealloc message. ### Phase 4: Verify Fix with Instruments (5 minutes) ``` 1. Product → Profile → Memory 2. Repeat action 10 times 3. Confirm: Memory stays flat (not climbing) 4. If climbing continues, go back to Phase 2 (second leak) ``` ## Compound Leaks (Multiple Sources) Real apps often have 2-3 leaks stacking: ``` Leak 1: Timer in PlayerViewModel (+10MB/minute) Leak 2: Observer in delegate (+5MB/minute) Result: +15MB/minute → Crashes in 13 minutes ``` #### How to find compound leaks ``` 1. Fix obvious leak (Timer) 2. Run Instruments again 3. If memory STILL growing, there's a second leak 4. Repeat Phase 1-3 for each leak 5. Test each fix in isolation (revert one, test another) ``` ## Memory Leak Detection — Testing Checklist ```swift // Pattern 1: Verify object deallocates @Test func viewModelDeallocates() { var vm: PlayerViewModel? = PlayerViewModel() vm?.startPlayback(Track(id: "1", title: "Test")) // Cleanup vm?.stopPlayback() vm = nil // If no crash, object deallocated } // Pattern 2: Verify timer stops @Test func timerStopsOnDeinit() { var vm: PlayerViewModel? = PlayerViewModel() let startCount = Timer.activeCount() vm?.startPlayback(Track(id: "1", title: "Test")) XCTAssertGreater(Timer.activeCount(), startCount) vm?.stopPlayback() vm = nil XCTAssertEqual(Timer.activeCount(), startCount) } // Pattern 3: Verify observer unregistered @Test func observerRemovedOnDeinit() { var vc: DetailViewController? = DetailViewController() let startCount = NotificationCenter.default.observers().count // Perform action that adds observer _ = vc vc = nil XCTAssertEqual(NotificationCenter.default.observers().count, startCount) } // Pattern 4: Memory stability over time @Test func memoryStableAfterRepeatedActions() { let vm = PlayerViewModel() var measurements: [UInt] = [] for _ in 0..<10 { vm.startPlayback(Track(id: "1", title: "Test")) vm.stopPlayback() let memory = ProcessInfo.processInfo.physicalMemory measurements.append(memory) } // Check last 5 measurements are within 10% of each other let last5 = Array(measurements.dropFirst(5)) let average = last5.reduce(0, +) / UInt(last5.count) for measurement in last5 { XCTAssertLessThan( abs(Int(measurement) — Int(average)), Int(average / 10) // 10% tolerance ) } } ``` ## Command Line Tools for Memory Debugging ```bash # Monitor memory in real-time # Connect device, then xcrun xctrace record --template "Memory" --output memory.trace # Analyze with command line xcrun xctrace dump memory.trace # Check for leaked objects instruments -t "Leaks" -a YourApp -p 1234 # Memory pressure simulator xcrun simctl spawn booted launchctl list | grep memory # Check malloc statistics leaks -atExit -excludeNoise YourApp ``` ## Common Mistakes ❌ **Using [weak self] but never calling invalidate()** - Weak self prevents immediate crash but doesn't stop timer - Timer keeps running and consuming CPU/battery - ALWAYS call `invalidate()` or `cancel()` on timers/subscribers ❌ **Invalidating timer but keeping strong reference** ```swift // ❌ Wrong timer?.invalidate() // Stops firing but timer still referenced // ❌ Should be: timer?.invalidate() timer = nil // Release the reference ``` ❌ **Assuming AnyCancellable auto-cleanup is automatic** ```swift // ❌ Wrong - if cancellable goes out of scope, subscription ends immediately func setupListener() { let cancellable = NotificationCenter.default .publisher(for: .myNotification) .sink { _ in } // cancellable is local, goes out of scope immediately // Subscription dies before any notifications arrive } // ✅ Right - store in property @MainActor class MyClass: ObservableObject { private var cancellables = Set() func setupListener() { NotificationCenter.default .publisher(for: .myNotification) .sink { _ in } .store(in: &cancellables) // Stored as property } } ``` ❌ **Not testing the fix** - Apply fix → Assume it's correct → Deploy - ALWAYS run Instruments after fix to confirm memory flat ❌ **Fixing the wrong leak first** - Multiple leaks = fix largest first (biggest memory impact) - Use Memory Graph to identify what's actually leaking ❌ **Adding deinit with only logging, no cleanup** ```swift // ❌ Wrong - just logs, doesn't clean up deinit { print("ViewModel deallocating") // Doesn't stop timer! } // ✅ Right - actually stops the leak deinit { timer?.invalidate() timer = nil NotificationCenter.default.removeObserver(self) } ``` ❌ **Using Instruments Memory template instead of Leaks** - Memory template: Shows memory usage (not leaks) - Leaks template: Detects actual leaks - Use both: Memory for trend, Leaks for detection ## Instruments Quick Reference | Scenario | Tool | What to Look For | |----------|------|------------------| | Progressive memory growth | Memory | Line steadily climbing = leak | | Specific object leaking | Memory Graph | Purple/red circles = leak objects | | Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak | | Memory by type | VM Tracker | Find objects consuming most memory | | Cache behavior | Allocations | Find objects allocated but not freed | ## Real-World Impact **Before** 50+ PlayerViewModel instances created/destroyed - Each uncleared timer fires every second - Memory: 50MB → 100MB (1min) → 200MB (2min) → Crash (13min) - Developer spends 2+ hours debugging **After** Timer properly invalidated in all view models - One instance created/destroyed = memory flat - No timer accumulation - Memory: 50MB → 50MB → 50MB (stable for hours) **Key insight** 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in `deinit` or use reactive patterns that auto-cleanup. --- ## Simulator Verification After fixing memory leaks, verify your app's UI still renders correctly and doesn't introduce visual regressions. ### Why Verify After Memory Fixes Memory fixes can sometimes break functionality: - **Premature cleanup** — Object deallocated while still needed - **Broken bindings** — Weak references become nil unexpectedly - **State loss** — Data cleared too early in lifecycle **Always verify**: - UI still renders correctly - No blank screens or missing content - Animations still work - App doesn't crash on navigation ### Quick Visual Verification ```bash # 1. Build with memory fix xcodebuild build -scheme YourScheme # 2. Launch in simulator xcrun simctl launch booted com.your.bundleid # 3. Navigate to affected screen xcrun simctl openurl booted "debug://problem-screen" sleep 1 # 4. Capture screenshot /axiom:screenshot # 5. Verify UI looks correct (no blank views, missing images, etc.) ``` ### Stress Testing with Screenshots Test the screen that was leaking, repeatedly: ```bash # Navigate to screen multiple times, capture at each iteration for i in {1..10}; do xcrun simctl openurl booted "debug://player-screen?id=$i" sleep 2 xcrun simctl io booted screenshot /tmp/stress-test-$i.png done # All screenshots should look correct (not degraded) ``` ### Full Verification Workflow ```bash /axiom:test-simulator ``` Then describe: - "Navigate to PlayerView 10 times and verify UI doesn't degrade" - "Open and close SettingsView repeatedly, screenshot each time" - "Check console logs for deallocation messages" ### Before/After Example **Before fix** (timer leak): ```bash # After navigating to PlayerView 20 times: # - Memory at 200MB # - UI sluggish # - Screenshot shows normal UI (but app will crash soon) ``` **After fix** (timer cleanup added): ```bash # After navigating to PlayerView 20 times: # - Memory stable at 50MB # - UI responsive # - Screenshot shows normal UI # - Console logs show: "PlayerViewModel deinitialized" after each navigation ``` **Key verification**: Screenshots AND memory both stable = fix is correct --- **Last Updated**: 2025-11-28 **Frameworks**: UIKit, SwiftUI, Combine, Foundation **Status**: Production-ready patterns for leak detection and prevention