Adding and removing NSButton (any NSControl) in a window leaks AppKit-internal objects. A plain NSView does not.
Environment: macOS 26.5.1 (25F80), Apple silicon (arm64), Swift, swiftc -O, no third-party code.
Repro (single file):
import AppKit
func mb()->Int{ var i=task_vm_info_data_t(); var c=mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size/4); withUnsafeMutablePointer(to:&i){ $0.withMemoryRebound(to:integer_t.self,capacity:1){ _=task_info(mach_task_self_,task_flavor_t(TASK_VM_INFO),$0,&c)}}; return Int(i.phys_footprint/1048576)}
let app = NSApplication.shared; app.setActivationPolicy(.accessory)
let mode=CommandLine.arguments[1]
let win=NSWindow(contentRect:NSRect(x:100,y:100,width:400,height:100),styleMask:[.titled],backing:.buffered,defer:false)
let stack=NSStackView(); win.contentView=stack; win.orderFrontRegardless()
var n=0
print(mode,"start",mb()); fflush(stdout)
Timer.scheduledTimer(withTimeInterval:0.002,repeats:true){ t in
autoreleasepool {
for v in stack.arrangedSubviews { stack.removeArrangedSubview(v); v.removeFromSuperview() }
for _ in 0..<10 {
let b: NSView = mode=="view" ? NSView(frame:.zero) : NSButton(title:"", target:nil, action:nil)
if mode=="plainadd" { win.contentView!.addSubview(b); b.removeFromSuperview() } else { stack.addArrangedSubview(b) }
}
}
n+=1
if n%500==0 { print(mode,"iter",n*10,mb()); fflush(stdout) }
if n>=2500 { t.invalidate() }
}
app.run()
swiftc -O repro.swift -o tw
./tw button & # then: heap $(pgrep -x tw) -s | grep -E "NSKeyValueDependency |__NSMallocBlock__|NSButton "
./tw view & # control case: plain NSView
Results (25,000 buttons created and removed; only 10 alive at a time):
NSMallocBlockbutton (NSButton in NSStackView) | 26 / 43 / 60 (steady growth) | 292,621 | 251,145 |
view (plain NSView) | flat at 13 | 172 | n/a |
heap shows only 10 live NSButton instances, so the buttons themselves are freed. leaks reports no leaks, so AppKit still holds references to the objects. That is about 12 objects (~3 KB) per button.
Allocation stack (MallocStackLogging=1 + malloc_history -callTree NSKeyValueDependency, taken on my real app):
-[NSStackView insertArrangedSubview:atIndex:]
-[NSView addSubview:]
-[NSView _setSuperview:]
-[NSView(NSViewRoot) _updateViewRoot]
-[NSControl _viewRootDidChange]
-[NSControl _setupGestureRecognizers]
__cons_NSDP_NSDPTypeBool_block_invoke.273
NSDP_getComputedPropertyValue
-[NSKeyValueDependencyInfo addDependencyInContext:onValueForKey:]
_objc_rootAllocWithZone
The allocation happens when an NSControl is added to a view hierarchy that has a window (_viewRootDidChange → _setupGestureRecognizers). The KVO dependency objects are never released after the control is removed and deallocated.
Real-world impact: an app rebuilt about 40 buttons every 5 s. That leaked about 40 objects per second (~16 MB/hour). After 19 days it reached 7.2 GB footprint, mostly small malloc blocks, with ~1.4M NSKeyValueDependency objects and ~1.4M blocks.
Questions:
- Is this a known issue? Is there a Feedback number?
- Is there a supported way to avoid or release these dependency objects, other than not recreating
NSControls? - Does it reproduce on macOS 26.x or 27 betas?
Workaround for now: reuse buttons and only rebuild when state actually changes.