Skip to content

fix(macos): survive non-string AX titles and stop leaking AXObserver - #145

Merged
ErikBjare merged 1 commit into
ActivityWatch:masterfrom
TimeToBuildBob:fix/macos-ax-title-crash
Sep 15, 2026
Merged

ErikBjare merged 1 commit into
ActivityWatch:masterfrom
TimeToBuildBob:fix/macos-ax-title-crash

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Summary

macOS window tracking dies in two related ways after a few days:

  1. Crash (macOS: native helper crashes when NSHTTPURLResponse is bridged as a window-title string #144). windowTitleChanged bridged kAXTitleAttribute with as? String. That path calls -[obj length] without a class check, so a non-NSString (here NSHTTPURLResponse) aborts the helper with NSInvalidArgumentException.
  2. Leak ([macOS] aw-watcher-window-macos leaks AXObserver/Mach ports, causing high wired memory #139). focusedAppChanged removed the run-loop source and then let AXObserverCreate overwrite the previous observer pointer without CFRelease. The old Mach receive port kept filling to qlimit (1024). Independently, focusedWindowChanged unregistered kAXFocusedWindowChangedNotification from the old window even though it had registered kAXTitleChangedNotification.
  3. Silent stop. The Python wrapper ignored the helper's wait status, so a SIGABRT still looked like a clean exit to aw-tauri and window tracking stayed off.

Changes

  • Read AX strings/elements via CF type ID (axString / axElement) instead of Swift's NSString bridge.
  • Tear down the previous observer: unregister title + focused-window notifications, drop the run-loop source, nil the refs, then create a new observer into a fresh out-pointer.
  • Map helper signal deaths (-6 → 134) to a non-zero wrapper exit so the module manager restarts the watcher.

Observer teardown follows the patch Gassandrid measured on #139 (200/2000 app-switch iterations, no port growth). I cannot compile the Swift helper on this Linux host; Python tests cover the exit-status path.

Related

Test plan

  • poetry run python -m pytest tests/test_main.py (19 passed)
  • poetry run mypy aw_watcher_window/ --ignore-missing-imports
  • On macOS: make build-swift
  • Switch apps rapidly and confirm aw-watcher-window-macos Mach port count stays bounded (sudo lsmp / sudo zprint)
  • Confirm a helper abort now makes aw-watcher-window exit non-zero so aw-tauri restarts it

Accessibility can return non-NSString values for kAXTitleAttribute.
Bridging those with `as? String` calls `-[obj length]` and aborts the
helper (NSHTTPURLResponse in ActivityWatch#144). Read titles via CF type checks.

Also tear down the previous AXObserver properly: unregister the title
and focused-window notifications, drop the run-loop source, and nil the
observer before AXObserverCreate. Overwriting the CF out-pointer leaked
Mach receive ports and wired memory (ActivityWatch#139).

If the helper still dies, propagate its signal/exit status so aw-tauri
restarts window tracking instead of treating the crash as a clean
shutdown.

Git-Session-Id: 03164cc9-6bb0-5435-925f-f425e310dded
@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob review

@TimeToBuildBob TimeToBuildBob left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against #144, #139 (Gassandrid's measured teardown), and aw-tauri's module manager. I authored this PR; this is the adversarial pass.

Verdict: land it. No blocking issues. GitHub will not let me approve my own PR.

Crash path (#144)

as? String on an AX value really does go through String._unconditionallyBridgeFromObjectiveC-[obj length] with no class check. NSHTTPURLResponse is exactly that trap. axString / axElement via CFGetTypeID is the right fix, and every AX title/role/focused-window read on this path is converted.

The leftover urlRef as? NSURL is a different bridge (class-checked) and is the URL success path; axString is only the CFString fallback.

Leak path (#139)

tearDownObserver matches the patch Gassandrid measured at 200 and 2,000 app-switch iterations (no Mach-port growth):

  1. Unregister kAXTitleChangedNotification on oldWindow
  2. Unregister kAXFocusedWindowChangedNotification on observedApp
  3. Drop the run-loop source
  4. Nil the refs so Swift ARC releases the AXObserver (the Mach-port owner)
  5. Create into a fresh out-pointer, not &observer

Two extras vs that patch, both correct: deinit also tears down, and pollActiveWindow no longer force-unwraps a nil observer. RunLoop.current for both add and remove is consistent — mixing main/current (as the #139 sketch did) would leak if those ever diverged.

Exit status

swift_helper_exit_status(-6) == 134 is the mapping Popen.wait() uses for SIGABRT. aw-tauri restarts only when !output.status.success() (manager.rs); exit 0 was the silent-off. stop_module sets modules_pending_shutdown before SIGTERM, so mapping helper SIGTERM → 143 will not restart during quit. aw-qt already treats any death-while-started as unexpected (dialog, no auto-restart) — this change is for the tauri path.

Tests cover the mapping and the swift-strategy SystemExit(134) path.

CI

macos-latest already compiled this:

swiftc -target arm64-apple-macosx12.0 aw_watcher_window/macos.swift

Succeeded. The two warnings (optional interpolation in the Chrome/Safari title-diff logs) are pre-existing. The unchecked make build-swift box in the PR body is stale.

Out of scope

  • #141 also rewrites observer lifecycle and will conflict. Land this first: it actually type-checks AX strings and unregisters the app-level focused-window notification; #141 does neither, and still has windowTitle as? String.
  • Chrome/Safari ScriptingBridge failure still returns without a heartbeat. That's #141, not this PR.
  • Remaining live check I cannot do here: Mach-port count under rapid app switching (sudo lsmp). Gassandrid already ran that harness against this teardown; CI compiled the helper but did not rerun it.

No code changes requested.

@ErikBjare
ErikBjare merged commit a82b6d1 into ActivityWatch:master Sep 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants