Skip to content

Menu.clear() never purges NSApp._ns_to_py_and_callback → unbounded memory leak for apps that rebuild menus #232

Description

@pjs-0457

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions