Skip to content

Memory leak in _update_observers (events.py:363) — AX observer refs accumulate at ~10–17 MB/min physical footprint #7

@keyadash

Description

@keyadash

Summary

macos-mcp exhibits a steady, linear physical footprint leak when AX-tree-reading tools are exercised in long-running sessions. Growth rate is roughly 10–17 MB/min of activity, and the process never releases memory after the calling client stops invoking tools. On a 32 GB host left running over a couple of days, the process climbed to ~16.5 GB physical footprint and contributed to a system-level OOM. With an external watchdog reaping the process at 400 MB, we observe repeated respawns under normal use; during active AX-tree-read loops, the process can reach the threshold in roughly 18–22 minutes.

The evidence points most strongly at AX observer references (AXObserverCreate / AXObserverAddNotification) may be created during these calls and not consistently paired with AXObserverRemoveNotification / explicit release, allowing AXUIElement retentions to accumulate inside pyobjc.

Environment

  • macos-mcp v2.14.4 (manifest 0.1.0)
  • macOS Tahoe 26.x on Apple silicon
  • Hosted as a local MCP server by an MCP client (Claude Desktop), which respawns the process on transport close

Symptom timeline

Watchdog-driven respawn log over a 48h window. The watchdog sends SIGTERM when the process’s footprint crosses 400 MB.

2026-05-25T05:45:09.633Z   transport closed   (footprint = 430 MB at kill)
2026-05-26T05:36:30.930Z   transport closed   (footprint = 432 MB at kill)
2026-05-26T06:46:36.707Z   transport closed   (footprint = 444 MB at kill)

Each fresh process starts at ~50–100 MB. Footprint climbs roughly linearly with tool-call activity. After calls stop, footprint does not decay, which is the tell that the retained objects are not just transient working memory.

Why footprint and not RSS. An earlier version of our watchdog used ps -o rss= as its threshold metric and silently failed against this leak class: the kernel was aggressively swapping/compressing the leaked dirty pages, so ps-reported RSS oscillated in the 33–301 MB range for hours while the actual physical footprint grew to 16.5 GB. We switched to footprint -f bytes <pid> (resident + swapped + compressed). Any generic memory monitor reading RSS will under-count this leak; footprint is the metric that surfaces it cleanly.

Suspected leak site

A sample(1) call-graph capture taken during steady-state growth pointed at:

macos_mcp/ax/events.py:363    _update_observers

with frames into:

  • _AXObserverAddNotificationAndCheckRemote
  • AXObserverCreate
  • a pyobjc retention loop around AXUIElement references

The shape is consistent with observers being attached during AX-tree reads (snapshot / scrape / similar) and—we suspect—the matching AXObserverRemoveNotification (or CFRelease / pyobjc __del__) not running when the wrapping Python object goes out of scope. Once an observer is attached to an AXUIElement, the element gets retained by the AX framework until the observer is explicitly removed; GC’ing the Python wrapper alone won’t release it.

Reproduction

  1. Connect macos-mcp to any MCP client.
  2. Call Snapshot repeatedly at a steady cadence for about 20 minutes against Finder and Safari. In our run, this produced roughly 10–17 MB/min footprint growth.
  3. Loop over Snapshot (or any tool that walks the accessibility tree) against a couple of running apps — e.g. Finder + Safari — for ~20 minutes.
  4. Watch footprint: footprint -f bytes <pid>
    It should climb roughly linearly with each call (~10–17 MB/min on our box).
  5. Stop calling tools. Footprint does not drop — suspected observers remain attached, and AXUIElement retentions appear to stay live.

A single 20-minute loop is enough to reproduce the growth; the OOM-class outcome only shows up if nothing reaps the process.

What we’re asking

  1. Confirm whether _update_observers in ax/events.py is the right site, or point at the actual offender if the sample misled us.
  2. Patch — most likely a matching AXObserverRemoveNotification in the dealloc path for whatever wraps the observer, or a weakref / explicit-release pattern so AXUIElement retentions get freed when the Python wrapper drops. If observers are intentionally long-lived, a bounded cache with eviction would also solve it.
  3. Regression-test — please add a small long-run AX-tree-read test or diagnostic that verifies observer count / footprint remains bounded after repeated Snapshot-style calls.
  4. Release — once patched, please cut a version that downstream MCP-client extension auto-updaters pick up. Happy to validate the candidate on our environment before release if that’s useful — the repro above produces a clean signal in under 30 minutes.

Notes

The leak is severe in long-running sessions, but the surface area looks small — possibly a missing release/removal path. Filing this with as much detail as we have so it’s cheap to triage on your side.

Supporting artifacts

  • 2026-05-20 macos-mcp memory leak sample.txt — full sample(1) output of the leaking process captured at peak (16.5 GB physical footprint). Contains the call graph with the _AXObserverAddNotificationAndCheckRemote / AXObserverCreate hot path that points at events.py:363.
  • 2026-05-20 macos-mcp vmmap summary.txtvmmap -summary of the same process. Relevant region table: MALLOC_SMALL at 16.4 GB virtual / 16.3 GB swapped across 4,187 regions, with ~49 million live allocations in the default malloc zone. Consistent with many small allocations remaining live; per-call attribution would need allocation-stack evidence.
  • 2026-05-26 claude desktop mcp-server-macos log excerpt.txt — last 48h of transport closed lines from the MCP client’s per-server log; time-correlates with the watchdog kill events.
  • 2026-05-21 macos-mcp watchdog v2 deployment verification.txt — the v1→v2 diff of our watchdog script plus real KILL pid=… footprint=… log lines proving the metric switch from ps -o rss= to footprint -f bytes works. Includes the rationale for why RSS-based monitoring missed this leak.
  • 2026-05-26 cursortouch macos-mcp manifest snapshot.json.txt — version metadata for the installed extension at the time of report

cc: @claude-dashnet

2026-05-26.claude.desktop.mcp-server-macos.log.excerpt.txt
2026-05-21.macos-mcp.watchdog.v2.deployment.verification.txt
2026-05-20.macos-mcp.vmmap.summary.txt
2026-05-20.macos-mcp.memory.leak.sample.txt
2026-05-26.cursortouch.macos-mcp.manifest.snapshot.json.txt

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions