Back to articlesArticle

I found a real leak in a 449-star SwiftUI library using memorydetective

Real production case study: memorydetective catalogued an NSKeyValueObservation leak in the notelet library. The maintainer merged the fix in less than 24 hours. 342 AVPlayerItem instances dropped to zero.

10 min

Anime illustration of the build com carlos character as a code detective, magnifier over a leaking pipe dripping AVPlayerItem instances, a 449-star badge.

I found a real leak in a 449-star SwiftUI library using memorydetective

Core thesis

Named antipattern catalogues are more useful than generic code analysis when the problem is runtime evidence. memorydetective has had kvo.observation-not-invalidated in the catalogue since day one. When I ran the tool against notelet - a SwiftUI library widely used to display release notes in iOS apps - the pattern jumped off the source. Result: 342 AVPlayerItem instances went to zero after a two-line PR the maintainer merged in less than 24 hours.

Who this article is for

  • iOS engineers who use or audit open-source libraries with NSKeyValueObservation, AVQueuePlayer, or any closure-based observer API.
  • People building debugging tools or agents that need to classify runtime evidence rather than just pattern-match on code.
  • Anyone curious about how a 36-pattern named catalogue fares against real code, not just synthetic fixtures.

Context

This week I sent a small PR to notelet, a popular SwiftUI library for displaying release notes in iOS apps. Two fixes, merged the next day. One was an NSKeyValueObservation that was never invalidated, leaking an AVPlayerItem every time the release-notes sheet was presented and dismissed.

I didn’t find it by reading the code carefully. I found it because memorydetective’s classifier has had kvo.observation-not-invalidated in the catalogue since day one, and the pattern jumped out the moment I scrolled MediaNoteItemVideoView.swift.

The pattern

The buggy code lived inside the video media view:

@State private var videoStatusObserver: NSKeyValueObservation?

func prepareVideo() {
    isVideoLoading = true
    let player = AVQueuePlayer(playerItem: AVPlayerItem(url: videoURL))
    videoStatusObserver = player.currentItem?.observe(\.status) { item, _ in
        // handle status changes
    }
    self.player = player
}

Two problems:

  1. videoStatusObserver never receives .invalidate(). When the view disappears, it stays alive holding the AVPlayerItem it observes.
  2. If videoURL changes (re-entry of task(id:)), prepareVideo() overwrites videoStatusObserver with a new one. The old observer leaks along with the player item it was observing.

memorydetective’s classifier surfaces exactly this shape as kvo.observation-not-invalidated:

obj.observe(\.x) { obj, change in ... } returns a token that strongly retains the change handler, and the handler typically captures self. Capture self weakly: obj.observe(\.x) { [weak self] _, _ in self?... }, and call token.invalidate() in deinit. Storing only the token doesn’t break the closure capture.

The classifier matches when a leaked memgraph has any class whose name contains NSKeyValueObservation or _NSKeyValueObservance. Pattern-level recognition based on runtime evidence, no fancy ML.

This pattern is also browsable as an MCP resource (memorydetective://patterns/kvo.observation-not-invalidated), which means an agent like Claude Code or Cursor can look it up by name and cross-reference with your code even before capturing a memgraph.

The demo

I built a 30-line SwiftUI app to confirm reproduction:

import SwiftUI
import Notelet

private let demoNotes: [NoteletVersionNotes] = [
    .init(version: "1.0.0", items: [
        .media(
            kind: .video,
            url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4")!,
            title: "Demo video",
            description: "Usado para amplificar o vazamento de AVPlayerItem ao longo dos ciclos."
        )
    ])
]

struct ContentView: View {
    @State private var presented: NoteletPresentedVersion? = nil

    var body: some View {
        Button("Present") { presented = .v("1.0.0") }
            .noteletSheet(notes: demoNotes, version: presented, onDismiss: { presented = nil })
    }
}

Boot the simulator, present the sheet, dismiss, repeat. Each cycle leaks one AVPlayerItem plus the KVO observer plus the associated buffered video data. Small in bytes per cycle (KB-scale, not MB), but it accumulates.

A note on macOS 26.x

memorydetective’s canonical verify-fix flow on the iOS Simulator is:

bootAndLaunchForLeakInvestigation → captureScenarioState (before)
   → aplicar o fix → captureScenarioState (after) → diffMemgraphs → verifyFix

When I ran on Xcode 26.4.1 / iOS 26.4 sim, the memgraph step of captureScenarioState failed with:

minimal-corpse: leaks --outputGraph could not introspect the target process (known regression on macOS 26.x). The target’s task port didn’t return DYLD info, so leaks aborted before writing the graph.

The tool handled this honestly: instead of returning silently or fabricating zero-leak data, it surfaced a structured workaroundNotice with three fallback paths. I tried two (heap, xctrace --template Allocations); both hit the same underlying macOS 26.x bug in task_for_pid against simulator processes. The third (manual Memory Graph export from Xcode) works but isn’t automatable.

This isn’t a memorydetective problem; it’s a deep regression on Apple’s side. What matters here is that the tool reports “I tried, here’s why it failed, here are the options” instead of returning a clean but meaningless result. For an AI-driven investigation loop, honest diagnostic surface is what holds everything up. A silent zero would have wasted hours.

The same family of macOS 26.x regressions hit xctrace record later: the --time-limit flag is ignored on simulator targets, the recording hangs past the window, and the resulting .trace bundle ends up missing template metadata, making xctrace export reject it. v1.14 closed the user-visible part of that loop with a 2-second pre-flight probe (MEMORYDETECTIVE_PREFLIGHT_XCTRACE, auto-enabled on macOS 26.x sim attach), an opt-in auto-open of Instruments.app on timeout (MEMORYDETECTIVE_AUTO_OPEN_INSTRUMENTS), and a README callout naming the bug as Apple-side. v1.16 added recordViaInstrumentsApp, an MCP tool that opens Instruments.app, returns step-by-step instructions, watches a directory for the saved .trace, and chains into inspectTrace as soon as the bundle appears. The user-in-loop step is the recording itself: Instruments.app exposes only document queries via AppleScript (no verbs for start/stop/template), so full GUI automation is impossible until Apple expands the dictionary. The wrapper covers everything around it.

The quantitative diff

I captured both memgraphs on an iOS 18 simulator (pre-regression, lets Xcode’s Memory Graph run), 10 present/dismiss cycles each, captured right after the last dismiss:

Class Pre-fix (notelet @ 50f21224) Post-fix (notelet @ 07d1fdb) Delta
AVPlayerItem 342 instances 0 −342
AVPlayerPlaybackCoordinator 290 0 −290
NSKeyValueObservance ~29 ~7 −22
Physical footprint 161.8 MB 153.0 MB −8.8 MB
Live heap nodes 64.887 59.954 −4.933

The pre-fix process accumulated 342 AVPlayerItem instances plus the entire AV plumbing tree (player items, playback coordinators, internal coordination state) over 10 cycles. Each cycle creates the player and some internal transients, all kept alive by the KVO observer that was never invalidated. The fix collapses the whole tree to zero.

A subtle but important note: leaks itself reports 0 leaks for both runs. The orphan objects are reachable through KVO’s global observer registry, so they don’t count as “leaked” in the strict reachability sense. They are abandoned memory: legitimately held by something, but functionally garbage. The growth only shows up via reference-tree inspection or heap diff. That’s exactly why the canonical “leakCount” metric underestimates this pattern’s impact, and why memorydetective shipped analyzeAbandonedMemory in v1.9 (the investigation that produced this post was the dogfood that prioritised it).

analyzeAbandonedMemory(beforePath, afterPath) diffs two memgraphs on per-class reference-tree counts. v1.10 shipped an actionableShrinkage[] field (filtered for framework noise like NSMutableDictionary / CFString / __DATA __bss) that puts user-relevant classes at the top. Running on both memgraphs from this post with outputFormat: "verify-fix-table" returns the comparison table directly:

# analyzeAbandonedMemory: verify-fix

## O que o fix liberou

| Classe | Before | After | Delta |
|---|---:|---:|---:|
| `AVCMNotificationDispatcher` | 1802 | 4 | -1798 |
| `__NSObserver` | 665 | 7 | -658 |
| `AVCMNotificationDispatcherListenerKey` | 603 | 0 | -603 |
| `AssetPropertyStore` | 346 | 0 | -346 |
| `AVPlayerItem` | 342 | 0 | -342 |
| `AVPlayerItemInternal` | 334 | 0 | -334 |
| `AVRetainReleaseWeakReference` | 325 | 0 | -325 |
| `AVPlayerInternal` | 297 | 0 | -297 |
| `AVPlayerPlaybackCoordinator` | 290 | 0 | -290 |

__NSObserver carries the notificationcenter-observer-leaked high classification automatically (the catalogue matches the name pattern). The AV* classes appear as unknown-growth high shrinkage entries: analyzeAbandonedMemory knows they shrank substantially and knows that’s the verify-fix signal, but can’t infer the catalogue reason from heap diff alone. The KVO root-cause attribution comes from the kvo.observation-not-invalidated pattern in the classifyCycle catalogue, which matches the shape on the source side regardless of strict-leak vs abandoned-memory framing. The 342-to-0 delta is no longer a manual count of leaks --debug=stacks --debug='AVPlayerItem$'; it’s a markdown table from one call.

Re-validation on v1.16: same data, more signal

I ran both memgraph files again through the v1.16 surface to confirm the post’s numbers reproduce end-to-end after a series of trace-side improvements. The reference-tree delta is bit-for-bit identical (AVCMNotificationDispatcher: 1802 to 4, the -1798 signature still anchors the verdict). Two newer features add useful angles to the same investigation:

1. countAlive size view (v1.14, FLEX-inspired). Pre-v1.14 countAlive only returned instance counts. v1.14 reads per-class size from the .memgraph (cycle-side [N] annotations plus per-class reference-tree totals) and exposes instanceSizeBytes + totalBytes plus a sortBy: "totalBytes" ranking. Same notelet memgraph, ranked by memory budget instead of instance count:

{
  "totalNodes": 64991,
  "counts": [
    { "className": "<< TOTAL >>",                   "instanceCount": 64991, "totalBytes": 17196646 },
    { "className": "NSMutableDictionary",           "instanceCount": 17742, "totalBytes":  2000568 },
    { "className": "NSMutableDictionary (Storage)", "instanceCount": 17043, "totalBytes":  1979854 },
    { "className": "CFString",                      "instanceCount": 12193, "totalBytes":  1285964 }
  ],
  "actionableCounts": [
    { "className": "Kernel Pointers Into User Space", "instanceCount": 233, "totalBytes": 799744 },
    { "className": "CGDataProvider",                  "instanceCount":  67, "totalBytes": 459674 },
    { "className": "CGImage",                         "instanceCount":  57, "totalBytes": 296787 }
  ]
}

The unfiltered top is framework noise (NSMutableDictionary, CFString); the filtered actionableCounts[] view surfaces SDK-level classes that scale with video activity. For the notelet case, the framework-noise filter is what makes the budget view actionable.

2. verifyFix whitelist (v1.14, MLeaksFinder + DebugSwift-inspired). Singletons, framework caches and OS-retained windows legitimately stay alive between before/after snapshots. Pre-v1.14 they landed in regressionClasses[] and could flip the verdict to PARTIAL. v1.14 added an expectedAliveClasses[] input (substring patterns, case-insensitive) plus a curated default list of Apple system classes (keyboard input VCs, prediction VCs, remote-keyboard windows, etc.). Hits go into a transparent expectedAlive[] field in the response instead of affecting the verdict.

Running verifyFix on the same notelet pair with a whitelist of ["SwiftUI.ObjectCache", "pthread_mutex_t", "NSCache"]:

{
  "overallVerdict": "PASS",
  "verdictSource": "abandoned-memory",
  "freedClasses": [/* 89 entries */],
  "regressionClasses": [/* 92 entries, caiu de 97 sem whitelist */],
  "expectedAlive": [
    "<SwiftUI.ObjectCache<SwiftUI.Font.(Resolved in $129311d90), __C.CTFontRef> 0x14f837fd0> [48]",
    "pthread_mutex_t",
    "NSCache._cache (struct cache_s)",
    "NSCache",
    "<SwiftUI.ObjectCache<SwiftUI.Color.Resolved, __C.CGColorRef> 0x1518450a0> [48]"
  ],
  "diagnosis": "Fix verified via abandoned-memory shrinkage: 10,386 instances freed dominates the residual 3,445-instance growth ..."
}

The substring match catches both the font and color variants of SwiftUI.ObjectCache, and matches both exact NSCache and the NSCache._cache storage. The regressionClasses count drops from 97 (without whitelist) to 92 (with), the growth magnitude drops from 4,531 to 3,445 instances, and the diagnosis names the user-relevant numbers without forcing the agent to re-rank manually.

Provenance: the bug is in the library, the demo app is just harness

A fair pushback on a post like this is “did your demo app introduce the leak?” It didn’t. The demo app is 30 lines of SwiftUI with a Button("Present") and a .noteletSheet(...) modifier. Zero AVFoundation imports, zero KVO observers, zero references to AVPlayer.

Each of the 342 leaked AVPlayerItem instances has the same allocation stack trace, captured via leaks --debug=stacks --debug='AVPlayerItem$'. Trimmed to the relevant frames:

_calloc
_objc_rootAllocWithZone
AVPlayerItem.init
MediaNoteItemVideoView.prepareVideo        ← fonte da notelet, o site da alocação
swift::runJobInEstablishedExecutorContext
... (frames Swift Concurrency + UIKit)
NoteletDemoApp.$main                       ← ponto de entrada de qualquer app iOS

MediaNoteItemVideoView lives in Sources/Notelet/MediaNoteItemVideoView.swift. NoteletDemoApp.$main only appears at the top of the stack because that’s where every iOS app starts. The actual allocation site is inside the library.

The control experiment is even simpler: between the two memgraph runs, the demo app source is byte-identical. The only thing that changed was the SPM dependency revision (50f2122407d1fdb). Same harness, same workflow, different notelet commit. The 342 → 0 delta is the fix.

The fix

The PR is mechanical:

  • onDisappear { videoStatusObserver?.invalidate(); videoStatusObserver = nil; player?.pause() }
  • The same invalidate-then-nil at the top of prepareVideo() before assigning a new observer.

Mykola Harmash, the maintainer, merged in less than 24 hours with “Thank you for the fixes!” and a HOORAY reaction. No back-and-forth, no review iteration. The PR description was concrete enough about the repro and failure mode that there was nothing left to debate.

The fix landed on main in commit 07d1fdb. It ships in the next release Mykola cuts.

Try it

memorydetective is open source (Apache 2.0):

  • npm: npm install -g memorydetective (v1.16.0)
  • GitHub: carloshpdoc/memorydetective
  • Claude Code plugin: /plugin marketplace add carloshpdoc/memorydetective-plugin

Exposes 41 MCP tools for iOS leak and perf debugging through any MCP client (Claude Code, Cursor, Cline, Claude Desktop, etc.). The cycle catalogue has 36 named patterns covering modern Swift (Observation, SwiftData, AsyncSequence-on-self, NavigationPath in viewmodels, WKScriptMessageHandler bridge, plus v1.9’s additions uikit.viewcontroller-retained-after-pop and swiftui.observable-write-on-every-render) on top of the classic UIKit / Core Animation / Combine / RxSwift / Realm shapes.

The trace-side surface grew from v1.11 through v1.16:

  • inspectTrace (v1.11) is the one-call orientation tool: it parses the trace TOC and returns the populated schemas plus suggestedNextCalls[] so the agent doesn’t chain analyzers blindly.
  • summarizeTrace (v1.13) is the synthesis move: one call runs five analyzers in parallel, builds cross-schema correlations (hangs overlapping with hitches becomes “main-thread block likely caused the dropped frames”), and pre-renders a compact markdown card (<10 KB) with a headline, per-area sub-sections and suggested next calls. v1.15 added network to the chain so the card surfaces verbose SDKs and slow endpoints alongside.
  • analyzeHangs (v1.14) reads both potential-hangs and hang-risks schemas and surfaces mainThreadViolations[] so each hang carries a classified cause (sync-io / db-lock / network / lock-contention).
  • Trace-recording wrap in v1.16: recordViaInstrumentsApp is the way out for macOS 26.x when xcrun xctrace record is broken. It opens Instruments.app, prompts through the record + save flow, watches a directory, and chains into inspectTrace.
  • Three new analyzers in v1.15: analyzeMemoryFootprint (peak resident / dirty / virtual, with the OOM-kill discriminator), analyzeEnergyImpact (idle / passive / active / high bucket classification plus wakeup counts), analyzeLeakTimeline (xctrace’s leaks instrument as a time series with first-seen-at per class).
  • The v1.9 CI surface continues: detectLeaksInXCTest and detectLeaksInXCUITest ship self-contained HTML reports for PR-gate use. cleanupTraces manages the trace-bundle directory. Safety env flags cover unattended-agent scenarios.

If you maintain or audit an iOS codebase, especially one using NSKeyValueObservation, AVQueuePlayer, or any block-based observer API, it’s worth running the catalogue against it.


Related posts: