TL;DR
- O classifier do memorydetective tem
kvo.observation-not-invalidatedno catálogo desde o dia um. Ele bateu na hora ao olhar para oMediaNoteItemVideoView.swiftda notelet. - PR de duas linhas, merged em menos de 24 horas pelo mantenedor. 342 instâncias de
AVPlayerItemviraram zero. - O caso virou dogfood: motivou
analyzeAbandonedMemory(v1.9), a whitelist doverifyFix(v1.14) e o wrapperrecordViaInstrumentsApp(v1.16).
Tese central
Catálogos nomeados de antipatterns são mais úteis do que análise de código de uso geral quando o problema é evidência de runtime. O memorydetective tem kvo.observation-not-invalidated no catálogo desde o dia um. Quando rodei a ferramenta contra a notelet, uma biblioteca SwiftUI bastante usada para mostrar release notes em apps iOS, o padrão pulou da fonte na hora. Resultado: 342 instâncias de AVPlayerItem viraram zero depois de um PR de duas linhas que o mantenedor mergeou em menos de 24 horas.
Para quem é este artigo
- Engenheiros iOS que usam ou auditam bibliotecas open source com NSKeyValueObservation, AVQueuePlayer ou qualquer API de observer baseada em closure.
- Quem está construindo ferramentas de debug ou agentes que precisam classificar evidência de runtime em vez de só fazer pattern matching de código.
- Pessoas curiosas sobre como um catálogo de 36 padrões nomeados se sai contra código real, não só fixtures sintéticas.
Contexto
Essa semana mandei um PR pequeno pra notelet, uma biblioteca SwiftUI popular para mostrar release notes em apps iOS. Dois fixes, merged no dia seguinte. Um deles era um NSKeyValueObservation que nunca era invalidado, vazando uma instância de AVPlayerItem a cada vez que a sheet de release notes era apresentada e dispensada.
Não encontrei lendo o código com calma. Encontrei porque o classifier do memorydetective tem kvo.observation-not-invalidated no catálogo desde o dia um, e o padrão pulou no momento que rolei o arquivo MediaNoteItemVideoView.swift.
O padrão
O código bugado vivia dentro da view de mídia de vídeo:
@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
}
Dois problemas:
videoStatusObservernunca recebe.invalidate(). Quando a view desaparece, ele continua vivo segurando o AVPlayerItem que observa.- Se
videoURLmuda (re-entrada dotask(id:)),prepareVideo()sobrescrevevideoStatusObservercom um novo. O observer antigo vaza junto com o player item que estava observando.
O classifier do memorydetective surfaceia exatamente essa forma como kvo.observation-not-invalidated:
obj.observe(\.x) { obj, change in ... }retorna um token que retém fortemente o handler de change, e o handler tipicamente capturaself. Capture self como weak:obj.observe(\.x) { [weak self] _, _ in self?... }, e chametoken.invalidate()emdeinit. Guardar só o token não quebra a captura da closure.
O classifier matcha quando um memgraph leaked tem qualquer classe cujo nome contenha NSKeyValueObservation ou _NSKeyValueObservance. Reconhecimento a nível de padrão, baseado em evidência de runtime, sem ML fancy.
Esse padrão também é browsável como recurso MCP (memorydetective://patterns/kvo.observation-not-invalidated), o que significa que um agente como Claude Code ou Cursor consegue buscá-lo por nome e cross-referenciar com o teu código antes mesmo de capturar um memgraph.
O demo
Montei um app SwiftUI de 30 linhas pra confirmar a reprodução:
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 })
}
}
Bootar o simulador, apresentar a sheet, dispensar, repetir. Cada ciclo vaza um AVPlayerItem mais o KVO observer mais os dados de vídeo buffered associados. Pouco em bytes por ciclo (escala de KB, não de MB), mas acumula.
Uma observação sobre macOS 26.x
O fluxo canônico de verify-fix do memorydetective no iOS Simulator é:
bootAndLaunchForLeakInvestigation → captureScenarioState (before)
→ aplicar o fix → captureScenarioState (after) → diffMemgraphs → verifyFix
Quando rodei no Xcode 26.4.1 / iOS 26.4 sim, o passo do memgraph do captureScenarioState falhou com:
minimal-corpse:leaks --outputGraphnão conseguiu introspectar o processo target (regressão conhecida no macOS 26.x). A task port do target não devolveu DYLD info, então o leaks abortou antes de escrever o graph.
A ferramenta lidou com isso honestamente: em vez de retornar silenciosamente ou fabricar dados de zero-leak, surfaceou um workaroundNotice estruturado com três caminhos de fallback. Tentei dois (heap, xctrace --template Allocations); ambos bateram no mesmo bug subjacente de macOS 26.x no task_for_pid contra processos de simulador. O terceiro (export manual do Memory Graph do Xcode) funciona mas não é automatizável.
Isso não é problema do memorydetective; é uma regressão profunda do lado da Apple. O que importa aqui é que a ferramenta reporta "tentei, foi por isso que falhou, aqui estão as opções" em vez de retornar um resultado limpo mas sem significado. Para um loop de investigação dirigido por IA, a superfície de diagnóstico honesta é a parte que sustenta tudo. Um zero silencioso teria desperdiçado horas.
A mesma família de regressões de macOS 26.x atingiu o xctrace record mais tarde: a flag --time-limit é ignorada em targets de simulador, a gravação trava além da janela e o bundle .trace resultante fica sem metadata de template, fazendo xctrace export rejeitá-lo. A v1.14 fechou a parte user-visível desse loop com uma probe de pre-flight de 2 segundos, e a v1.16 adicionou recordViaInstrumentsApp como saída GUI-assistida.
O diff quantitativo
Capturei os dois memgraphs num simulador iOS 18 (que é pré-regressão e deixa o Memory Graph do Xcode rodar), 10 ciclos present/dismiss cada, capturado logo depois do último dismiss:
| Classe | Pré-fix (notelet @ 50f21224) |
Pós-fix (notelet @ 07d1fdb) |
Delta |
|---|---|---|---|
AVPlayerItem | 342 instâncias | 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 |
O processo pré-fix acumulou 342 instâncias de AVPlayerItem mais toda a árvore de plumbing AV (player items, playback coordinators, estado interno de coordenação) ao longo de 10 ciclos. Cada ciclo cria o player e alguns transitórios internos, todos mantidos vivos pelo KVO observer que nunca foi invalidado. O fix colapsa a árvore inteira pra zero.
Uma nota sutil mas importante: o leaks em si reporta 0 leaks para ambas as runs. Os objetos órfãos são alcançáveis pelo registry global de observers do KVO, então não contam como "leaked" no sentido estrito de reachability. Eles são abandoned memory: legitimamente segurados por algo, mas funcionalmente lixo. O crescimento só aparece via inspeção de reference-tree ou heap diff. É exatamente por isso que a métrica canônica "leakCount" subestima o impacto desse padrão, e por que o memorydetective lançou analyzeAbandonedMemory na v1.9 (a investigação que produziu esse post foi a dogfood que priorizou isso).
analyzeAbandonedMemory(beforePath, afterPath) faz diff entre dois memgraphs nas counts de classe por reference-tree. A v1.10 lançou um campo actionableShrinkage[] (filtrado para framework noise como NSMutableDictionary / CFString / __DATA __bss) que põe as classes relevantes pro usuário no topo. Rodando nos dois memgraphs deste post, com outputFormat: "verify-fix-table", retorna a tabela de comparação direto:
# 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 carrega a classificação notificationcenter-observer-leaked high automaticamente (o catálogo bate no padrão do nome). As classes AV* aparecem como entradas unknown-growth high de shrinkage: o analyzeAbandonedMemory sabe que elas encolheram substancialmente, sabe que esse é o sinal de verify-fix, mas não consegue inferir a razão do catálogo só pelo heap diff. A atribuição de causa raiz do KVO vem do padrão kvo.observation-not-invalidated do catálogo classifyCycle, que matcha a forma do lado da fonte independente do framing strict-leak vs abandoned-memory. O delta 342 para 0 não é mais uma contagem manual de leaks --debug=stacks --debug='AVPlayerItem$'; é uma tabela markdown de uma chamada.
Re-validação na v1.16: mesmos dados, mais sinal
Rodei os dois arquivos de memgraph de novo pela superfície da v1.16 pra confirmar que os números do post se reproduzem end-to-end depois de uma série de melhorias no trace side. O delta de reference-tree é bit-for-bit idêntico (AVCMNotificationDispatcher: 1802 para 4, a assinatura -1798 continua ancorando o verdict). Duas features mais novas adicionam ângulos úteis à mesma investigação:
1. countAlive size view (v1.14, FLEX-inspired). Pré-v1.14 o countAlive retornava só counts de instância. A v1.14 lê o tamanho por classe do .memgraph (anotações cycle-side [N] mais totais reference-tree por classe) e expõe instanceSizeBytes + totalBytes mais um ranking sortBy: "totalBytes". Mesmo memgraph da notelet, ranqueado por budget de memória em vez de count de instância:
{
"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 }
]
}
O topo sem filtro é framework noise (NSMutableDictionary, CFString); a view filtrada actionableCounts[] surfaceia as classes a nível de SDK que escalam com a atividade de vídeo. Para o caso da notelet, o filtro de framework noise é o que torna a budget view actionable.
2. verifyFix whitelist (v1.14, MLeaksFinder + DebugSwift-inspired). Singletons, caches de framework e windows retidas pelo OS legitimamente continuam vivos entre snapshots before/after. Pré-v1.14 eles caíam em regressionClasses[] e podiam virar o verdict para PARTIAL. A v1.14 adicionou um input expectedAliveClasses[] (substring patterns, case-insensitive) mais uma lista default curada de classes de sistema da Apple. Hits vão pra um campo transparente expectedAlive[] no response em vez de afetar o verdict.
Rodando verifyFix no mesmo par notelet com uma whitelist de ["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 ..."
}
O substring match captura tanto a variante font quanto color de SwiftUI.ObjectCache, e matcha tanto NSCache exato quanto NSCache._cache storage. O count de regressionClasses cai de 97 (sem whitelist) para 92 (com), a magnitude de growth cai de 4.531 para 3.445 instâncias, e o diagnosis nomeia os números relevantes pro usuário sem forçar o agente a re-ranquear manualmente.
Procedência: o bug é na biblioteca, o demo app é só harness
Um pushback justo num post desses é "será que o teu demo app introduziu o leak?" Não introduziu. O demo app é 30 linhas de SwiftUI com um Button("Present") e um modifier .noteletSheet(...). Zero imports de AVFoundation, zero KVO observers, zero referências a AVPlayer.
Cada uma das 342 instâncias leaked de AVPlayerItem tem o mesmo stack trace de alocação, capturado via leaks --debug=stacks --debug='AVPlayerItem$'. Cortado para os frames relevantes:
_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.prepareVideo é o site de alocação dentro da biblioteca.MediaNoteItemVideoView vive em Sources/Notelet/MediaNoteItemVideoView.swift. NoteletDemoApp.$main só aparece no topo do stack porque é onde todo app iOS começa. O site real da alocação está dentro da biblioteca.
O experimento de controle é ainda mais simples: entre as duas runs de memgraph, o source do demo app é byte-identical. A única coisa que mudou foi a revisão da dependência SPM (50f21224 → 07d1fdb). Mesmo harness, mesmo workflow, commit diferente da notelet. O delta 342 → 0 é o fix.
O fix
O PR é mecânico:
onDisappear { videoStatusObserver?.invalidate(); videoStatusObserver = nil; player?.pause() }- O mesmo invalidate-then-nil no topo de
prepareVideo()antes de atribuir um observer novo.
Mykola Harmash, o mantenedor, mergeou em menos de 24 horas com "Thank you for the fixes!" e uma reação HOORAY. Sem ida-e-volta, sem iteração de review. A descrição do PR foi concreta o suficiente sobre o repro e o modo de falha que não sobrou nada pra debater.
O fix landed no main no commit 07d1fdb. Cai no próximo release que o Mykola cortar.
Try it
memorydetective é open source (Apache 2.0):
- npm:
npm install -g memorydetective(v1.16.0) - GitHub: carloshpdoc/memorydetective
- Plugin do Claude Code:
/plugin marketplace add carloshpdoc/memorydetective-plugin
Expõe 41 ferramentas MCP para debug de leak e perf no iOS através de qualquer cliente MCP (Claude Code, Cursor, Cline, Claude Desktop, etc.). O catálogo de ciclos tem 36 padrões nomeados cobrindo Swift moderno (Observation, SwiftData, AsyncSequence-on-self, NavigationPath em viewmodels, WKScriptMessageHandler bridge, mais as adições da v1.9 uikit.viewcontroller-retained-after-pop e swiftui.observable-write-on-every-render) em cima das formas clássicas de UIKit / Core Animation / Combine / RxSwift / Realm.
Se tu mantém ou audita uma codebase iOS, especialmente uma que usa NSKeyValueObservation, AVQueuePlayer, ou qualquer API de observer baseada em block, vale a pena rolar o catálogo.