Apps that rebuild their menu on a timer or on state change leak memory without bound, because the per-item callback registry is never purged when items are removed.
MenuItem.set_callback registers each item in the module-global dict NSApp._ns_to_py_and_callback (a plain dict, class attribute of NSApp). Menu.clear() only does:
def clear(self):
self._menu.removeAllItems()
super(Menu, self).clear()
It removes items from the native NSMenu and from the Menu OrderedDict, but never deletes the corresponding entries from NSApp._ns_to_py_and_callback. Each cleared item's entry keeps the NSMenuItem, the MenuItem wrapper, and the callback alive forever.
Any app that periodically rebuilds its menu (a common pattern — clear + re-add on a timer or on state change) therefore grows without bound. A real-world case reached ~962 MB after 8 days.
Minimal reproduction
import os, subprocess
from rumps.rumps import NSApp, Menu, MenuItem
from Foundation import NSAutoreleasePool
def rss_mb():
return int(subprocess.run(["ps","-o","rss=","-p",str(os.getpid())],
capture_output=True, text=True).stdout) / 1024
menu = Menu()
for i in range(1, 20001):
pool = NSAutoreleasePool.alloc().init() # mimic the run loop draining
menu.clear()
menu.update([MenuItem(f"item {n}", callback=lambda _: None) for n in range(15)])
del pool
if i % 5000 == 0:
print(f"{i} rebuilds -> registry {len(NSApp._ns_to_py_and_callback)}, RSS {rss_mb():.0f} MB")
5000 rebuilds -> registry 75000, RSS 133 MB
10000 rebuilds -> registry 150000, RSS 225 MB
15000 rebuilds -> registry 225000, RSS 327 MB
20000 rebuilds -> registry 300000, RSS 407 MB
The registry grows by exactly the item count each rebuild and never shrinks (the RSS figures vary a little from run to run; the registry size is deterministic).
Notes toward a fix
A bare pop() in clear() is not sufficient on its own:
- The registry also keys
SliderMenuItem by _slider and text fields by their view target, so a fix must handle all target types.
- Items re-inserted after removal must re-register their callback.
- Replacing a same-title item drops the old native item from the title-keyed
Menu dict, so purging must be driven by the native itemArray(), not the Python dict.
- Regression coverage for
clear, single-item delete, subtrees, re-use, and slider/text-field would be worth adding.
Related: #64, #216.
Apps that rebuild their menu on a timer or on state change leak memory without bound, because the per-item callback registry is never purged when items are removed.
MenuItem.set_callbackregisters each item in the module-global dictNSApp._ns_to_py_and_callback(a plaindict, class attribute ofNSApp).Menu.clear()only does:It removes items from the native
NSMenuand from theMenuOrderedDict, but never deletes the corresponding entries fromNSApp._ns_to_py_and_callback. Each cleared item's entry keeps theNSMenuItem, theMenuItemwrapper, and the callback alive forever.Any app that periodically rebuilds its menu (a common pattern — clear + re-add on a timer or on state change) therefore grows without bound. A real-world case reached ~962 MB after 8 days.
Minimal reproduction
The registry grows by exactly the item count each rebuild and never shrinks (the RSS figures vary a little from run to run; the registry size is deterministic).
Notes toward a fix
A bare
pop()inclear()is not sufficient on its own:SliderMenuItemby_sliderand text fields by their view target, so a fix must handle all target types.Menudict, so purging must be driven by the nativeitemArray(), not the Python dict.clear, single-item delete, subtrees, re-use, and slider/text-field would be worth adding.Related: #64, #216.