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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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