Skip to content

fix: reduce idle CPU usage on macOS (tray title dedup, pause hidden-w…#2186

Open
Code-newWorld wants to merge 1 commit into
chen08209:mainfrom
Code-newWorld:fix-macos-cpu-drain
Open

fix: reduce idle CPU usage on macOS (tray title dedup, pause hidden-w…#2186
Code-newWorld wants to merge 1 commit into
chen08209:mainfrom
Code-newWorld:fix-macos-cpu-drain

Conversation

@Code-newWorld

Copy link
Copy Markdown

…indow polling)

On newer macOS versions (especially with TUN enabled), FlClash keeps a constant 40-50% CPU load in the background. Four independent per-second hot paths cause this:

  • Tray.updateTrayTitle called trayManager.setTitle every second even when the value was unchanged or the tray title was disabled. Each setTitle triggers a full NSStatusItem redraw on recent macOS. Now the last title is cached and the native call is skipped when unchanged.
  • trayTitleState subscribed to per-second traffic updates even with showTrayTitle off. It now returns a constant state in that case.
  • The 1s runtime/traffic poll timer now skips its two core IPC round-trips while the window is hidden and the tray title is off (nothing consumes those values). Resumes automatically on show.
  • getTraffic/getTotalTraffic invocations no longer emit two log entries per second each; other methods keep logging as before.
  • Request push messages from the core (one per connection under TUN) are no longer JSON-parsed while the window is hidden.

No behavior change while the window is visible.

…indow polling)

On newer macOS versions (especially with TUN enabled), FlClash keeps a
constant 40-50% CPU load in the background. Four independent per-second
hot paths cause this:

- Tray.updateTrayTitle called trayManager.setTitle every second even
  when the value was unchanged or the tray title was disabled. Each
  setTitle triggers a full NSStatusItem redraw on recent macOS. Now the
  last title is cached and the native call is skipped when unchanged.
- trayTitleState subscribed to per-second traffic updates even with
  showTrayTitle off. It now returns a constant state in that case.
- The 1s runtime/traffic poll timer now skips its two core IPC
  round-trips while the window is hidden and the tray title is off
  (nothing consumes those values). Resumes automatically on show.
- getTraffic/getTotalTraffic invocations no longer emit two log entries
  per second each; other methods keep logging as before.
- Request push messages from the core (one per connection under TUN)
  are no longer JSON-parsed while the window is hidden.

No behavior change while the window is visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Surfire

Surfire commented Jul 10, 2026

Copy link
Copy Markdown

I tested the tray-title part of this PR on a real multi-display macOS setup and can confirm the root cause and the effectiveness of the deduplication.

Environment

  • macOS 26.5.1
  • Apple Silicon
  • 3 displays: built-in, 4K external with scaled rendering, and a portrait 1080p display
  • "Displays have separate Spaces" enabled
  • FlClash 0.8.93
  • showTrayTitle=false

Before / after

Official build before the fix:

  • FlClash GUI: 36.7%-40.0% CPU while hidden
  • 5-second sample: 542 samples under NSStatusItem _updateReplicants

Local build with tray-title deduplication plus the current tray_manager self-drawn-title fix:

  • FlClash GUI: 0.0% in 10 consecutive idle samples
  • FlClashCore: 0.0%-0.7%
  • no _updateReplicants or sizeToFit frames in the installed app's 5-second sample
  • proxy remained functional (generate_204 returned 204)

A native harness that changed the tray title every second stayed at 0.1%-0.5% CPU. It had only 3 incidental _updateReplicants samples out of 4,349 main-thread samples and no sizeToFit hot path.

Validation used Flutter 3.41.9: flutter analyze clean, all 353 tests passed, and the macOS Release build succeeded.

Two implementation suggestions

  1. Assign _lastTrayTitle = title after await trayManager.setTitle(title). If the platform call fails, assigning before the await suppresses retries for the same title.
  2. Reset _lastTrayTitle in destroy(), and add a MethodChannel test covering both deduplication and cache reset.

The title deduplication alone addresses the disabled-title case from #1644. The hidden-window polling and request-parsing changes touch broader behavior, so splitting them from the minimal tray fix may make review and regression analysis easier.

@Code-newWorld

Copy link
Copy Markdown
Author

Thanks for the thorough multi-display validation — really helpful.

I've addressed your implementation notes:

  1. _lastTrayTitle is now assigned only after a successful await trayManager.setTitle(title), so a failed platform call can retry the same title.
  2. destroy() already resets _lastTrayTitle; I added MethodChannel tests covering deduplication, cache reset after destroy, and the failed-setTitle retry path.

Agreed on splitting. I've opened a minimal tray-title-only PR here: #2201

The hidden-window polling / request-parsing / silent-IPC changes can follow in a separate PR.

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.

2 participants