Skip to content

Migrate macOS ObjC FFI to objc2, fixing the menu-bar leak (#99)#131

Merged
AprilNEA merged 4 commits into
masterfrom
refactor/macos-objc2
Jun 5, 2026
Merged

Migrate macOS ObjC FFI to objc2, fixing the menu-bar leak (#99)#131
AprilNEA merged 4 commits into
masterfrom
refactor/macos-objc2

Conversation

@AprilNEA
Copy link
Copy Markdown
Owner

@AprilNEA AprilNEA commented Jun 5, 2026

Why

Issue #99 reports memory climbing to 1 GB+. There were two distinct things behind it:

  1. A real leak. status_item.rs::nsstring() returned a +1-owned NSString (NSString::alloc(nil).init_str) that was never released, and it ran on every 2 s tray refresh via set_title. That is exactly the ~26k leaked CFStrings the reporter's leaks run found. The root cause isn't one missing release — it's the representation: cocoa/objc 0.x model every object as a raw, hand-retained id, so retain/release is an unenforced runtime discipline.
  2. Footprint churn. enumerate_hidpp_devices() rebuilt a whole HidBackend (a macOS IOHIDManager it schedules and, on drop, cancels) on every ~2 s inventory tick — needless create/teardown that kept the process busy and its heap dirty around the clock.

What this does

Migrate the macOS Objective-C FFI from cocoa/objc 0.x to objc2 (already in the tree via gpui/async-hid), so the leak class can't be written:

  • Retained<T> owns each AppKit object and releases it once on Drop. nsstring() and the autorelease-pool workaround are gone — every string is NSString::from_str(s) used as a borrowed temporary. A +1-with-no-owner is unrepresentable.
  • The OpenLogiMenuTarget click target is now a define_class! subclass with the event channel in a typed #[ivars] (no ClassDecl/add_method, no OnceLock<MENU_TX> read in the callbacks).
  • NSMenu/NSMenuItem are MainThreadOnly (!Send), so the tray's retained state moves from OnceLock statics into a main-thread thread_local — "main-thread only" is enforced by the type system, not a doc comment.
  • permissions.rs (CBCentralManager.authorization) and the hook's frontmost_bundle_id (NSWorkspace, which is AnyThread so it's sound on the watcher thread) also move to objc2. cocoa/objc 0.x leave both crates' direct dependencies (they remain in Cargo.lock only transitively via gpui).

Reuse one HID backend in a LazyLock instead of rebuilding it per poll — the usage async-hid intends; it also keeps the device set warm between polls.

Plus a scoped crates/openlogi-gui/src/platform/CLAUDE.md documenting the conventions.

What deliberately stays

The CGEventTap stays on core-graphics/CF. objc2-core-graphics does expose the tap API, but the Accessibility-revoke freeze-hazard run-loop machinery is load-bearing — out of scope here, byte-for-byte unchanged. Only the NSWorkspace read in that file moved.

Verification

  • cargo fmt --check, full-workspace cargo clippy --all-targets -- -D warnings, and all tests pass (hook+hid 26, gui 11).
  • The zed/gpui-component git pins in Cargo.lock are unchanged — the lock diff is only the new objc2-* crates.io packages.
  • Runtime, against a live MX Master 4: the dev bundle launches with no crash/panic; the computermouse.fill status item renders; the menu builds correctly (AX dump: device row MX Master 4 · NN%, separator, Open OpenLogi, Quit OpenLogi, spare rows hidden); and clicking Quit OpenLogi exits the app — confirming the define_class! action callback → ivar channel → cx.quit() chain fires end to end.

Closes #99.

AprilNEA added 3 commits June 5, 2026 14:46
enumerate_hidpp_devices() built a fresh HidBackend on every ~2 s inventory
tick. On macOS that backend wraps an IOHIDManager which it schedules and, on
drop, cancels — so each tick spun an IOHIDManager up and tore it down, needless
churn that kept the process busy and its heap dirty around the clock. Hold one
long-lived backend in a LazyLock (the usage async-hid intends); it also keeps
the device set warm between polls.

Refs #99.
The status-item/tray code modelled every AppKit object as a raw, hand-retained
`id` over cocoa/objc 0.x. nsstring() returned a +1 NSString that was never
released and ran on the 2 s tray refresh — issue #99's tens of thousands of
leaked CFStrings.

Rebuild the surface on objc2: Retained<T> owns each object and frees it on Drop,
so the leak can't be written. NSString::from_str replaces nsstring() (and the
autorelease-pool workaround is gone); the OpenLogiMenuTarget click target is now
a define_class! subclass holding the channel in a typed ivar; NSMenu/NSMenuItem
are MainThreadOnly (!Send), so the tray's retained state moves from OnceLock
statics into a main-thread thread_local. permissions.rs's CBCentralManager lookup
and the hook's NSWorkspace frontmost-app read also move to objc2, dropping
cocoa/objc from both crates' direct dependencies (they remain only transitively
via gpui).

The CGEventTap stays on core-graphics: objc2-core-graphics does expose the tap
API, but the Accessibility-revoke freeze-hazard run-loop machinery is load-bearing
and out of scope here.

Closes #99.
Scoped CLAUDE.md for platform/: the objc2 ownership model (Retained<T>, no
manual retain/release, no nsstring()), the thread-affinity rules
(MainThreadOnly vs AnyThread, MainThreadMarker, the tray thread_local), the
remaining unsafe surface, why CGEventTap stays on core-graphics, and the
gpui-pin / Metal-env build caveats. Captures the issue-#99 lesson so the leak
class isn't reintroduced.
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 5, 2026

Greptile Summary

This PR migrates the macOS Objective-C FFI from cocoa/objc 0.x to objc2, fixing the issue-#99 memory climb caused by a +1-owned NSString leaked on every 2 s tray refresh, and eliminates the heap churn from rebuilding IOHIDManager per poll tick.

  • Leak fix: nsstring() and all raw-id ownership are gone. Every AppKit object is now a Retained<T> that releases on Drop; NSString::from_str is used as a borrowed temporary directly in method calls.
  • Tray refactor: OnceLock statics replaced by a main-thread thread_local! holding a typed TrayState; the OpenLogiMenuTarget click target becomes a define_class! subclass with the event channel in a typed #[ivars], removing the OnceLock<MENU_TX> global.
  • Backend reuse: enumerate_hidpp_devices and open_route_writer now share a single LazyLock<HidBackend> instead of building a fresh IOHIDManager on every call; NSWorkspace and CBCentralManager reads in the hook and permissions code also migrate to objc2.

Confidence Score: 5/5

Safe to merge — the migration correctly eliminates the CFString leak and IOHIDManager churn, and the new objc2 type discipline makes the fixed class of bugs structurally unrepresentable.

The objc2 migration is mechanically sound: every AppKit object is now owned by a Retained that releases on Drop, the define_class! MenuTarget wires the event channel through typed ivars rather than a global OnceLock, and the thread_local TrayState makes the MainThreadOnly constraint a type-system guarantee rather than a doc comment. The LazyLock fix is the correct async-hid usage. No correctness issues found in the changed code paths.

No files require special attention — all changed files were reviewed and the logic is consistent throughout.

Important Files Changed

Filename Overview
crates/openlogi-gui/src/platform/status_item.rs Full rewrite from raw-id wrapper types to objc2 Retained<T> helpers; the two remaining unsafe calls (designated initializer and setTarget:) are correctly wrapped with sound SAFETY comments.
crates/openlogi-gui/src/platform/tray.rs OnceLock statics replaced by a main-thread thread_local holding TrayState; MenuTarget migrated to define_class! with typed ivars; debug_assert_main_thread guards added to all mutators.
crates/openlogi-hid/src/transport.rs Adds LazyLock so enumerate_hidpp_devices and open_route_writer share one long-lived IOHIDManager instead of creating and tearing one down per poll.
crates/openlogi-hook/src/macos.rs frontmost_bundle_id migrated from raw cocoa/objc FFI to objc2; NSWorkspace (AnyThread) called inside autoreleasepool, bundle_id.to_str(pool) correctly copied to owned String before pool drops.
crates/openlogi-gui/src/platform/permissions.rs CBCentralManager.authorization migrated from objc 0.x Class::get/msg_send! to objc2 AnyClass::get with c-string literal; logic unchanged.
crates/openlogi-gui/Cargo.toml cocoa/objc 0.x replaced by objc2 0.6.4 + objc2-app-kit 0.3.2 + objc2-foundation 0.3.2 with explicit feature flags; unexpected_cfgs allow removed.
crates/openlogi-hook/Cargo.toml cocoa/objc 0.x replaced by objc2 + objc2-foundation + objc2-app-kit (NSWorkspace, NSRunningApplication features); unexpected_cfgs allow removed.

Sequence Diagram

sequenceDiagram
    participant GPUI as GPUI main thread
    participant TL as TRAY thread_local
    participant MT as MenuTarget (define_class!)
    participant AppKit as AppKit (NSMenu click)
    participant Chan as mpsc channel
    participant Drain as drain task

    GPUI->>TL: install(tx) via TRAY.with_borrow_mut
    TL->>MT: MenuTarget::new(mtm, tx.clone())
    Note over MT: alloc -> set_ivars(tx) -> super-init
    TL->>TL: "slot = Some(TrayState)"

    AppKit->>MT: openOpenLogi: / quitOpenLogi:
    MT->>Chan: ivars().tx.send(event)
    Chan->>Drain: "recv -> cx.open() / cx.quit()"

    GPUI->>TL: set_device_lines(lines)
    TL->>TL: "setTitle on Retained<NSMenuItem> (no leak)"

    GPUI->>TL: uninstall()
    TL->>TL: "slot.take() -> TrayState drops -> all Retained<T> release"
Loading

Reviews (2): Last reviewed commit: "refactor(macos): document shared-backend..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/transport.rs
Comment thread crates/openlogi-gui/src/platform/tray.rs
Addresses the Greptile review on #131 (both non-blocking):

- transport.rs: document why the shared HID_BACKEND is safe under concurrent
  callers (the 2s watcher + open_route_writer) — async-hid declares the backend
  Send+Sync, enumerate only reads an IOHIDManagerCopyDevices snapshot, and one
  shared long-lived IOHIDManager is hidapi's model too.
- tray.rs: debug_assert_main_thread() on the tray mutators. They are already
  memory-safe off-main (the !Send TrayState only exists in the main thread's
  TLS, so they no-op), but a future off-main caller is now a loud debug-build
  failure instead of a silent no-op. Free in release.
@AprilNEA
Copy link
Copy Markdown
Owner Author

AprilNEA commented Jun 5, 2026

Addressed both non-blocking questions in 30287ce:

  1. Shared HID_BACKEND under concurrent callers — documented why it's sound: async-hid declares the backend Send + Sync, enumerate only reads an IOHIDManagerCopyDevices snapshot, and sharing one long-lived IOHIDManager across threads is the same model hidapi uses. No behaviour change.
  2. Tray mutators with no MainThreadMarker — added debug_assert_main_thread() to them. They were already memory-safe off-main (the !Send TrayState only lives in the main thread's TLS, so an off-main call no-ops rather than touching AppKit), but a future off-main caller is now a loud debug-build failure instead of a silent no-op. Free in release.

@AprilNEA AprilNEA merged commit ef0f02a into master Jun 5, 2026
8 checks passed
@AprilNEA AprilNEA deleted the refactor/macos-objc2 branch June 5, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory continuously increases to over 1GB+

1 participant