Last updated: 2026-07-21 (master @ 7bae4d5, app version 3.7.0).
This document arranges every issue and PR that refers to the long-running memory-growth problem, records what each one proved, and lays out the plan for the remaining work. The problem is macOS-specific in every confirmed report; the leaked memory is native (Foundation/Quartz/IOKit), not Python objects.
| Issue | Title | State | Status |
|---|---|---|---|
| #150 | High memory usage, 1.29 GB (macOS 26, MX Master 4, BLE) | Closed | Fixed by PR #151 (bounded dispatch backlog), merged 2026-05-12. |
| #233 | High memory usage, grows over time / with click count (v3.6.0–v3.7.0, MX Master 2S/3S, Bluetooth; 4 reporters) | Open | Partially fixed by PR #242 (click path confirmed fixed). Scroll path still leaks (~7–8 MB/min while scrolling); one tester still hits 700 MB in ~4.5 h on the PR-242 build. |
| #238 | Repeated HID reconnects leak IOHIDManager objects + Mach ports → 3.3 GB (macOS 26.4.1, MX Master, BLE) |
Open | Core leak fixed by PR #240 and validated on-device (managers pinned at 2, ports 67,862 → 2,646). A separate HIDEvent/CGEvent/GPProcessMonitor retention path remains (~926 MB after 20 h). |
Near-matches excluded from scope: #134 (Win ARM64 native build — battery/architecture request), PR #180 (DPI persistence), PR #212 (G602 catalog). They matched the keyword search only.
| PR | Title | State | Addresses | Outcome |
|---|---|---|---|---|
| #125 | @_autoreleased on CGEventTap callback + get_foreground_exe |
Merged 2026-04-24 | ~1.4 GB / 2-day leak | Fixed event-tap ingestion and added a pool to the app poller. GPProcessMonitor still accumulates despite the pool (see §2.3). |
| #151 | Bound hook dispatch backlogs (512-entry queue, drop-oldest) | Merged 2026-05-12 | #150 | Prevents unbounded Python-side event backlog. Cross-platform. |
| #240 | Close IOHIDManager + exception-safe open/close + reconnect backoff + REPROG_V4 probe cooldown |
Open | #238 | Validated by two testers (maxhis 20 h run, mizi 32 h run). Manager/port explosion resolved. Residual growth is a different signature. |
| #242 | Autorelease pools on action-execution threads (_dispatch_worker, key_simulator injection entry points) |
Open | #233 | Validated for clicks (click-only burst: +4 MB ≈ noise, vs. clear growth on v3.7.0). Not sufficient: scroll-only test still leaks +19 MB / 2.5 min. |
| #243 | Split macOS daemon from on-demand Qt UI | Open (draft) | Baseline footprint (complementary to the leaks) | Resident process drops ~280 MB → ~48 MB by keeping the PySide6 runtime out of the daemon. Substantial architectural change; author requests maintainer feedback on IPC/packaging. |
#150 (closed) ── fixed by ── #151 (merged) Python-side backlog, done
#233 (open) ─┬─ partially fixed by #242 (open) click path ✅, scroll path ❌
└─ remaining: HID read-loop pool → Phase 1 below
#238 (open) ─┬─ core leak fixed by #240 (open) managers/ports ✅, validated
└─ remaining: HIDEvent/GPProcessMonitor retention → Phases 1–2 below
#125 (merged) ── same residual signature persists on macOS 26.4.1 → Phases 1–2
#243 (draft) ── independent baseline reduction → Phase 4 below
The residual growth reported on both open issues after their respective fix PRs has the same heap signature, so #233 and #238 converge on the same remaining root causes.
Post-fix test data (maxhis' 20 h heap capture on the #240 build; bdhwrsh's 10 h sampling on the #242 build):
| Retained class | Count / size after 20 h | Likely source |
|---|---|---|
HIDEvent |
1,572,804 / 168 MB | IOHID input reports delivered on the HID gesture thread |
CGSEventAppendix + CGEvent |
~524k / 96 MB | Quartz event machinery on un-pooled threads |
GPProcessMonitor → NSXPCConnection, dispatch_queue_t, … |
8,892 monitors; leaks ROOT LEAK, 19.5 MB + supporting tree |
NSWorkspace.frontmostApplication() polling |
IOHIDManager |
2 (stable — fixed by #240) | — |
Behavioral measurements on the #242 build (bdhwrsh, macOS 27 beta, MX Master 3S):
- Idle/asleep 8 h: flat (536 MB, zero growth).
- Active use: ~110 MB/h linear growth.
- Scroll-only 2.5 min: +19 MB. Click-only ~100 presses: +4 MB (noise).
- Their config diverts the hi-res wheel (
0x2121, mul=15) and thumb wheel (0x2150, divertedRes=120) — wheel events arrive at far higher rates than clicks, and each diverted notification is an IOHID input report.
_MacNativeHidDevice.read() pumps the run loop on the HID gesture thread
(core/hid_gesture.py:700, CFRunLoopRunInMode in 0.05 s slices) and input
reports are delivered into _on_input_report (core/hid_gesture.py:670)
inside that pump. Nothing on that thread ever drains an autorelease pool on
master:
- PR #125 pooled the CGEventTap callback thread — different thread.
- PR #242 pools the injection entry points (
execute_action,inject_scroll, …) — coverage starts only after the gesture recognizer decides to act. The per-report Foundation/IOKit temporaries created by the run-loop delivery itself (theHIDEventcluster) are never drained.
This precisely fits "scroll leaks, clicks don't" on the #242 build: every wheel
detent produces ~15 diverted reports through this pump. It was explicitly
declared out of scope in #240 ("native read-loop pool… can follow separately").
Note that 7bae4d5 (#244) made hi-res scroll survive reconnects, which
increases diverted-wheel traffic — making this fix more urgent, not less.
Longer-interval engine threads (battery poll every 1800 s, smart-shift poll every 300 s, reconnect loop) touch HID/Foundation without pools. Low rate, so they are not the main driver, but they should be wrapped while we are at it so no macOS thread that crosses into native frameworks is left bare.
AppDetector calls NSWorkspace.frontmostApplication() every 0.3 s
(core/app_detector.py:364, poll loop at :384) — ~288,000 calls/day. The
call has been pool-wrapped since #125, yet leaks still reports the
GPProcessMonitor tree as a ROOT LEAK: the retention is inside the framework's
XPC machinery, so pooling alone cannot reclaim it. The only robust mitigation
is to make detection event-driven (NSWorkspace
didActivateApplicationNotification) so the call count collapses from ~288k/day
to the number of actual app switches.
skinnyshy reports ~430 MB/h growth on the #242 build "even on standby", while bdhwrsh measured a flat idle. Possible explanations: different idle definitions (display asleep vs. machine in use but untouched), BLE reconnect cycles during standby (the #240 fix is not in the #242 branch — the two builds each carry only their own fix), or diverted-wheel traffic from incidental motion. First validation step in Phase 1 is a build containing both fixes to remove this confound.
- Merge #240 (
fix/238-macos-hid-manager-leak). Two independent on-device validations confirm the manager/port leak is gone; the residual growth is a separate signature. Rebase on master first — both #240 and #244 touchcore/hid_gesture.py. - Merge #242. The click-path fix is confirmed by A/B measurement; it is strictly an improvement. Note in the merge comment that #233 stays open for the scroll path.
- Open a new focused issue — "macOS: HIDEvent/CGEvent/GPProcessMonitor retention on long-running sessions" — capturing §2's residual signature and linking #233/#238 (maxhis already offered to track it separately). Close #238 once #240 is merged, pointing to the new issue for the remainder; keep #233 open until Phase 1 is validated.
- Ask testers to hold further long-run measurements until a master build contains both merges (removes the confound in §2.4).
-
Pool the HID read-loop pump: drain an autorelease pool around the
CFRunLoopRunInModepump in_MacNativeHidDevice.read()(core/hid_gesture.py:691-710). Draining once perread()call bounds the cost (the call already returns at ≤0.05 s granularity); if profiling shows pool-churn overhead at high wheel rates, drain every N iterations instead. Implementation detail: this file uses rawctypes, not PyObjC — either reuseobjc.autorelease_pool()guarded by availability (askey_simulatordoes in #242), or bindNSAutoreleasePoolviactypes/objc_msgSendso the native backend keeps working without PyObjC. -
Wrap the gesture-thread report-consumption loop and the auxiliary threads (§2.2) with the same helper: reconnect loop, battery poll, smart-shift poll. One shared decorator, applied at thread entry points.
Status: steps 5–6 are implemented on this branch —
core/hid_gesture.pynow bindsNSAutoreleasePoolvialibobjc(ctypes, no PyObjC dependency, graceful no-op fallback) and drains a pool around eachCFRunLoopRunInModepump iteration inread()plus theenumerate_infos/open/write/closeentry points, covering every thread that crosses into IOKit/Foundation at the choke point. Awaiting on-device validation (step 7). -
Validate with the reporters' existing protocols (they have offered): bdhwrsh's scroll-only vs. click-only A/B plus 10 h footprint sampling; maxhis'
heap -s/leaks --groupByTypebefore/after — expected result:HIDEventcount bounded, scroll-only delta ≈ click-only delta ≈ noise. -
Release v3.7.1 with #240 + #242 + this fix once two reporters confirm flat memory over ≥24 h.
- Replace the 0.3 s macOS poll in
AppDetectorwithNSWorkspace.didActivateApplicationNotification(event-driven; keep the poll as a fallback and on Windows/Linux where no equivalent leak exists). Per-app profiles keep working identically —_on_changesemantics are unchanged, only the trigger differs. - Re-validate with
leaks --nostacks --groupByType: the ROOT LEAKGPProcessMonitortree should stop growing entirely.
- Lightweight memory self-reporting: log process footprint (macOS
phys_footprintviatask_info, RSS elsewhere) at startup and hourly at debug level. Every future report then carries the growth curve in the log file users already attach to the issue template. - Regression tests: extend #240's throttle tests with a high-rate
synthetic wheel-report replay through the recognizer asserting bounded
Python-side object counts; document the macOS-only manual checklist
(
vmmap/heap/leaksstress steps from #238) inDEVELOPMENT.mdas a release gate, since CI runners are Linux and cannot exercise IOKit/Foundation paths. - Track an upstream radar/feedback for the
GPProcessMonitorframework leak (it reproduces with a pooled caller on macOS 26.4.1/27 beta) so it can be linked from the code comment.
- Review draft #243 (daemon/UI process split, ~280 MB → ~48 MB resident). This attacks the baseline, not the leaks, and is complementary. Requested feedback for the author: IPC auth/packaging boundaries, signing implications for the nested helper app, and a rebase once Phase 0–1 fixes land. Substantial change from a first-time contributor — schedule a real review rather than letting the draft go stale.
| Order | Work | Vehicle | Closes |
|---|---|---|---|
| Now | Merge #240, #242; open residual-leak issue | existing PRs | #238 (core) |
| Next | Read-loop + thread pools (§Phase 1) | new PR → v3.7.1 | #233, residual issue (HIDEvent part) |
| Then | Event-driven app detection (§Phase 2) | new PR → v3.8 | residual issue (GPProcessMonitor part) |
| Parallel | Footprint logging + regression tests (§Phase 3) | new PR → v3.8 | — |
| Parallel | #243 review/iteration (§Phase 4) | draft PR → v3.8+ | — |
Reproducible measurement commands, standardized from #238 so all reporters produce comparable numbers:
PID="$(pgrep -x Mouser)"
top -l 1 -pid "$PID" -stats pid,command,cpu,mem,ports,threads,time
vmmap -summary "$PID"
heap -s -H "$PID"
leaks --nostacks --groupByType "$PID"Acceptance criteria for calling the memory problem fixed:
IOHIDManagercount constant (2) across reconnect storms — already met by #240.- Mach ports return near baseline after reconnect activity — substantially met by #240.
HIDEvent/CGEventcounts bounded during sustained scrolling — Phase 1.GPProcessMonitorROOT LEAK stops growing — Phase 2.- Physical footprint flat (±50 MB) over 24 h of active use on BLE — overall.