Skip to content

fix: reduce idle CPU usage from 79% to near zero#395

Merged
datlechin merged 4 commits intomainfrom
fix/high-cpu-energy-usage
Mar 21, 2026
Merged

fix: reduce idle CPU usage from 79% to near zero#395
datlechin merged 4 commits intomainfrom
fix/high-cpu-energy-usage

Conversation

@datlechin
Copy link
Collaborator

@datlechin datlechin commented Mar 21, 2026

Summary

Fixes #394. TablePro was consuming 79.3% CPU and 54.7 Energy Impact when idle. Root cause analysis identified 5 independent sources of unnecessary sustained work:

  • EditorEventRouter: NSWindow.didUpdateNotification with object: nil fired 60-120x/sec for ALL windows. Replaced with per-window scoped observers that only check their own editor.
  • VimCursorManager: Infinite CABasicAnimation (.repeatCount = .infinity) ran the compositor continuously even when unfocused. Added pauseBlink()/resumeBlink() on editor focus/blur and app active/resign.
  • InlineSuggestionManager: Scroll observer (boundsDidChangeNotification) was installed at startup and fired on every pixel of scroll. Now lifecycle-scoped to only exist while ghost text is displayed.
  • ContentView/MainContentView: .onChange(of: connectionStatusVersions[id]) observed the entire @Observable dictionary, triggering re-evaluation for ALL connections on any mutation. Replaced with targeted NotificationCenter publisher filtered by connection ID.
  • SuggestionController (CESS): didUpdateNotification for first responder tracking replaced with KVO on window.firstResponder which only fires on actual changes.

Test plan

  • Open app, connect to a database, leave idle for 30s — verify CPU drops from ~79% to <5% in Activity Monitor
  • Click editor → click data grid → click editor — verify focus/blur works correctly
  • Enable Vim mode — verify cursor blinks when focused, shows solid when unfocused, pauses when switching apps
  • Type SQL to trigger inline suggestion — verify ghost text appears and follows scroll. Dismiss and verify no scroll overhead
  • Connect 2 databases in separate windows — disconnect one, verify only that window updates
  • Trigger autocomplete popup — click outside to dismiss, verify it closes properly
  • Open/close tabs rapidly — verify no focus tracking issues

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed excessive CPU usage (79%+) and energy consumption when idle
    • Improved connection status synchronization and session management stability
    • Fixed Vim cursor blink behavior during window focus changes
    • Enhanced first-responder handling and text view detection
  • Performance

    • Improved UI responsiveness and optimized animation rendering

@coderabbitai
Copy link

coderabbitai bot commented Mar 21, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR addresses high CPU usage and energy consumption (#394) through systematic architectural optimization: replacing polling-based state observation with notification-driven updates, converting process-global observers to per-component handlers, deferring observer setup until needed, centralizing cleanup logic, adding deallocation checks in long-running loops, consolidating redundant state derivations, eliminating repeated formatter instantiation, removing unnecessary animations, and optimizing conditional checks.

Changes

Cohort / File(s) Summary
Notification & Observer Architecture
TablePro/Core/Services/Infrastructure/AppNotifications.swift, TablePro/Core/Database/DatabaseManager.swift
Added connectionStatusDidChange notification constant and wired DatabaseManager to post notifications when session state changes, establishing foundation for event-driven updates across views.
Connection Status Synchronization
TablePro/ContentView.swift, TablePro/Views/Main/MainContentView.swift
Replaced onChange polling with notification-driven connection status handling; extracted logic into handleConnectionStatusChange() helpers and added closingSessionId guards to prevent redundant updates during session closure.
Observer Lifecycle Management
LocalPackages/CodeEditSourceEditor/.../SuggestionController.swift, TablePro/Views/Editor/EditorEventRouter.swift, TablePro/Core/Vim/VimCursorManager.swift, TablePro/Views/Editor/SQLEditorCoordinator.swift
Switched from global/process-level observers to per-component KVO and notification tracking; added app active-state handling for Vim cursor blink; implemented proper observer installation/cleanup with reentrancy guards.
Suggestion & Scroll Observer Optimization
TablePro/Core/AI/InlineSuggestionManager.swift
Deferred scroll observer setup until ghost text display, centralized teardown via removeScrollObserver() helper, and added idempotent installation to prevent duplicate observers.
Thread Safety & Deallocation Handling
TablePro/Core/Services/Formatting/SQLFormatterService.swift, TablePro/Core/Services/Infrastructure/AnalyticsService.swift, TablePro/Core/Services/Licensing/LicenseManager.swift, TablePro/Core/Services/Query/SchemaProviderRegistry.swift
Added explicit nil-checks after sleep intervals and task-cancellation logic to prevent main-thread access from background threads and unnecessary operations on deallocated objects; introduced safe resolveDialectProvider dispatch wrapper.
Formatting & Caching
TablePro/Extensions/String+HexDump.swift, TablePro/Views/Editor/HistoryPanelView.swift, TablePro/Views/Structure/ClickHousePartsView.swift
Eliminated per-call formatter instantiation by caching formatters (DateFormatter, NumberFormatter) at class/extension level; replaced custom NumberFormatter setup with Swift's built-in Int.formatted().
UI State & Animation Refinements
TablePro/Views/AIChat/AIChatPanelView.swift, TablePro/Views/Connection/ConnectionFormView.swift, TablePro/Views/Filter/FilterRowView.swift, TablePro/Views/QuickSwitcher/QuickSwitcherView.swift, TablePro/Views/Main/Child/MainEditorContentView.swift
Removed unnecessary .onChange handlers and animation modifiers; consolidated multi-field triggers into single derived properties; simplified enumeration logic and added early-exit guards.
Performance Optimization
TablePro/Views/Editor/SQLEditorView.swift, TablePro/Models/Query/QueryResult.swift
Optimized cursor position handler to skip expensive full string comparison and rely only on length check; enhanced QueryResultRow equality to include element count validation.
Metadata
CHANGELOG.md
Added changelog entry documenting fix for high CPU usage (79%+) and energy consumption when idle as tracked in #394.

Sequence Diagram(s)

sequenceDiagram
    participant DBM as DatabaseManager
    participant NC as NotificationCenter
    participant CV as ContentView<br/>(or MainContentView)
    participant UI as UI Components

    rect rgba(200, 220, 255, 0.5)
    Note over DBM,UI: New: Notification-Driven Architecture
    DBM->>DBM: Session state changes
    DBM->>NC: post(.connectionStatusDidChange)
    NC->>CV: deliver notification
    CV->>CV: handleConnectionStatusChange()
    CV->>UI: update AppState & views
    end

    rect rgba(255, 220, 200, 0.5)
    Note over DBM,UI: Old: Polling via onChange
    CV->>CV: onChange(connectionStatusVersions)
    CV->>DBM: read version array
    CV->>UI: conditional update
    end
Loading
sequenceDiagram
    participant App as NSApplication
    participant VCM as VimCursorManager
    participant Layer as CABasicLayer
    participant UI as Blink Animation

    App->>VCM: didResignActive / didBecomeActive
    VCM->>VCM: pauseBlink() / resumeBlink()
    VCM->>VCM: toggle isPaused flag
    alt isPaused == true
        VCM->>Layer: stop "blink" animation
    else isPaused == false
        VCM->>Layer: reattach blink animation
        Layer->>UI: animate cursor blink
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A hop through efficiency,
Observers dance less, notify more,
Animations fade to whispers,
Formatters cached, threads now safer—
The CPU breathes a sigh of relief.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive All changes directly support the CPU reduction objective. However, some formatting optimizations (NumberFormatter, DateFormatter caching, String.formattedAsHexDump, Int.formatted) and the QueryResultRow equality check appear tangentially related or incidental to the primary issue. Clarify whether formatting optimizations and QueryResultRow equality changes are performance improvements related to #394 or unrelated improvements that should be separated into a distinct PR for scope isolation.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: reduce idle CPU usage from 79% to near zero' accurately summarizes the main objective of the PR, which is fixing issue #394 by substantially reducing CPU consumption during idle states.
Linked Issues check ✅ Passed The PR addresses all coding-related requirements from issue #394: eliminates global observers and infinite animations, scopes observers to per-window/lifecycle usage, ensures correct behavior across focus changes and interactions, and fixes five root causes of CPU waste.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/high-cpu-energy-usage

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift`:
- Around line 141-153: The KVO callback can act on a stale window after the
DispatchQueue.main hop; capture the observed window identity and verify it
before proceeding: inside the parentWindow.observe(\.firstResponder...) closure
save the observed window (e.g., let observedWindow = window) and then in the
DispatchQueue.main.async block ensure parentWindow === observedWindow (or
observedWindow === parentWindow) before reading model.activeTextView or walking
the responder chain; only then perform the existing checks and call close() as
necessary to avoid closing a session for a different window.

In `@TablePro/ContentView.swift`:
- Around line 340-360: This block needs to "latch" the view to the session being
closed before deferring the window close to avoid repopulating for a
newly-selected session; set a short-lived marker (e.g., a property like
sessionClosingId or isQueuedForClose) on ContentView when you enter the
guard-failure branch (where currentSession?.id == sid and you teardown
rightPanelState/sessionState) and clear it after the async close completes, then
update any logic that repopulates the view to ignore changes to currentSession
while sessionClosingId/isQueuedForClose == sid/true; reference ContentView,
currentSession, sessions[sid], DispatchQueue.main.async, rightPanelState and
sessionState to find where to latch and where to check.

In `@TablePro/Views/Main/MainContentView.swift`:
- Around line 787-798: The lazy-load block guarded by session.isConnected &&
coordinator.needsLazyLoad currently only checks changeManager.hasChanges; update
it to also guard against any pending tab-level edits so restored tabs don't get
overwritten: before clearing coordinator.needsLazyLoad and calling
coordinator.runQuery() or coordinator.switchDatabase(to:), check
selectedTab.pendingChanges.hasChanges (via
tabManager.selectedTab?.pendingChanges.hasChanges) and return early if true;
ensure you reference the same symbols (session.isConnected,
coordinator.needsLazyLoad, changeManager.hasChanges,
tabManager.selectedTab.pendingChanges.hasChanges, coordinator.runQuery(),
coordinator.switchDatabase(to:)) so the early-exit mirrors the other key-window
lazy-load path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 705d37d9-306f-4daf-9aec-84c1e5c75c0c

📥 Commits

Reviewing files that changed from the base of the PR and between fbf0bc9 and 7e8bf7d.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift
  • TablePro/ContentView.swift
  • TablePro/Core/AI/InlineSuggestionManager.swift
  • TablePro/Core/Database/DatabaseManager.swift
  • TablePro/Core/Services/Infrastructure/AppNotifications.swift
  • TablePro/Core/Vim/VimCursorManager.swift
  • TablePro/Views/Editor/EditorEventRouter.swift
  • TablePro/Views/Editor/SQLEditorCoordinator.swift
  • TablePro/Views/Main/MainContentView.swift

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
TablePro/Views/Editor/SQLEditorView.swift (1)

55-63: ⚠️ Potential issue | 🟡 Minor

Length-only check no longer guarantees text match before cursor propagation.

At Line 61, equal-length but different-content text now passes this guard, so stale cursor positions can still be propagated during tab recreation. That weakens the safety check described in the comment above.

Suggested fix
                 if let controller = coordinator.controller {
                     let currentString = controller.textView.string as NSString
                     let bindingString = text as NSString
-                    if currentString.length != bindingString.length {
+                    if currentString.length != bindingString.length || currentString != bindingString {
                         return
                     }
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TablePro/Views/Editor/SQLEditorView.swift` around lines 55 - 63, The current
guard compares only lengths (controller.textView.string and text) which allows
different-content strings of equal length to pass; replace the length-only check
in the coordinator.controller block with a full content equality check between
controller.textView.string and the binding text (e.g., compare the two
NSString/String values for equality) and return if they differ so cursor
propagation only happens when the editor text exactly matches the binding;
update the check in the block referencing coordinator.controller,
controller.textView.string and text accordingly.
🧹 Nitpick comments (2)
TablePro/Views/AIChat/AIChatPanelView.swift (2)

46-53: Minor redundancy: viewModel.tables is set in both .onAppear and .task(id:).

The .task(id: tables) modifier runs on initial appear as well as on changes to tables, so the assignment on line 48 is redundant with line 51. Consider removing line 48 to consolidate the logic.

♻️ Suggested simplification
         .onAppear {
             viewModel.connection = connection
-            viewModel.tables = tables
         }
         .task(id: tables) {
             viewModel.tables = tables
             await fetchSchemaContext()
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TablePro/Views/AIChat/AIChatPanelView.swift` around lines 46 - 53, The
viewModel.tables assignment is duplicated: it’s set in both .onAppear and in
.task(id: tables); remove the redundant assignment inside the .onAppear block
and keep viewModel.tables = tables inside the .task(id: tables) so table updates
and initial load are handled in fetchSchemaContext, while preserving
viewModel.connection = connection in .onAppear; update AIChatPanelView.swift to
delete the viewModel.tables = tables line from the .onAppear closure.

171-178: O(n²) complexity from firstIndex(where:) inside ForEach loop.

Each iteration calls firstIndex(where:) which is O(n), making the overall message rendering O(n²). While chat message counts are typically modest, this is less efficient than the previous enumerated approach.

Consider using zip or enumerated() to avoid the repeated linear search:

♻️ Suggested fix using enumerated
-                    ForEach(viewModel.messages) { message in
+                    ForEach(Array(viewModel.messages.enumerated()), id: \.element.id) { msgIndex, message in
                         if message.role != .system {
                             // Extra spacing before user messages to separate conversation turns
-                            if message.role == .user,
-                               let msgIndex = viewModel.messages.firstIndex(where: { $0.id == message.id }),
-                               msgIndex > 0,
-                               viewModel.messages[msgIndex - 1].role == .assistant
+                            if message.role == .user,
+                               msgIndex > 0,
+                               viewModel.messages[msgIndex - 1].role == .assistant
                             {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TablePro/Views/AIChat/AIChatPanelView.swift` around lines 171 - 178, The
ForEach over viewModel.messages uses firstIndex(where:) inside the loop
(searching by message.id) causing O(n²) rendering; change the iteration to an
indexed form (use enumerated() or zip with indices) so you have the current
index available instead of calling firstIndex(where:), then check the previous
message via that index (e.g., inspect viewModel.messages[index - 1].role) to
decide spacing; update the ForEach usage and the conditional that currently
references firstIndex(where:) and message.id to use the new index-based access.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@TablePro/Core/Services/Formatting/SQLFormatterService.swift`:
- Around line 117-124: The code currently resolves the SQL dialect while holding
keywordRegexLock, causing a potential deadlock; change SQLFormatterService to
move dialect resolution out of the locked section by calling a new helper (e.g.,
resolveDialect(for:)) that obtains the SQLDialectProvider via
SQLDialectFactory.createDialect(for:) / MainActor.assumeIsolated or
DispatchQueue.main.sync as needed, then re-acquire keywordRegexLock only to
cache the provider/result; also replace the duplicate dialect-resolution code in
format(...) with a call to that shared helper so both sites use the same
non-blocking pattern and avoid holding keywordRegexLock across main-queue sync.

In `@TablePro/Extensions/String`+HexDump.swift:
- Around line 11-15: The shared static NumberFormatter hexDumpNumberFormatter is
not thread-safe; remove the hexDumpNumberFormatter property and replace its
usages (e.g., in the hex dump producing method that currently calls
hexDumpNumberFormatter.string(...) around line 73) with the modern, thread-safe
API by calling Int.value.formatted(.number) (or
String(integer.formatted(.number))) where the formatter was used; update any
code paths that expected an optional string from NumberFormatter to handle the
non-optional formatted result and drop the static formatter declaration.

In `@TablePro/Views/Editor/HistoryPanelView.swift`:
- Around line 305-307: In buildSecondaryMetadata(_ entry: QueryHistoryEntry)
replace the hardcoded prefix "Executed: " with a localized string by using
String(localized:) (e.g. String(localized: "Executed: {0}", entry.executedAt) or
compose String(localized:) for the prefix and then append
Self.metadataDateFormatter.string(from: entry.executedAt)); update the code
around buildSecondaryMetadata and Self.metadataDateFormatter usage so the
displayed prefix is produced via String(localized:) rather than a literal.

---

Outside diff comments:
In `@TablePro/Views/Editor/SQLEditorView.swift`:
- Around line 55-63: The current guard compares only lengths
(controller.textView.string and text) which allows different-content strings of
equal length to pass; replace the length-only check in the
coordinator.controller block with a full content equality check between
controller.textView.string and the binding text (e.g., compare the two
NSString/String values for equality) and return if they differ so cursor
propagation only happens when the editor text exactly matches the binding;
update the check in the block referencing coordinator.controller,
controller.textView.string and text accordingly.

---

Nitpick comments:
In `@TablePro/Views/AIChat/AIChatPanelView.swift`:
- Around line 46-53: The viewModel.tables assignment is duplicated: it’s set in
both .onAppear and in .task(id: tables); remove the redundant assignment inside
the .onAppear block and keep viewModel.tables = tables inside the .task(id:
tables) so table updates and initial load are handled in fetchSchemaContext,
while preserving viewModel.connection = connection in .onAppear; update
AIChatPanelView.swift to delete the viewModel.tables = tables line from the
.onAppear closure.
- Around line 171-178: The ForEach over viewModel.messages uses
firstIndex(where:) inside the loop (searching by message.id) causing O(n²)
rendering; change the iteration to an indexed form (use enumerated() or zip with
indices) so you have the current index available instead of calling
firstIndex(where:), then check the previous message via that index (e.g.,
inspect viewModel.messages[index - 1].role) to decide spacing; update the
ForEach usage and the conditional that currently references firstIndex(where:)
and message.id to use the new index-based access.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ed74c06-85e9-4309-8381-3a5b370b9596

📥 Commits

Reviewing files that changed from the base of the PR and between 7e8bf7d and 1ff61d9.

📒 Files selected for processing (15)
  • TablePro/Core/Services/Formatting/SQLFormatterService.swift
  • TablePro/Core/Services/Infrastructure/AnalyticsService.swift
  • TablePro/Core/Services/Licensing/LicenseManager.swift
  • TablePro/Core/Services/Query/SchemaProviderRegistry.swift
  • TablePro/Extensions/String+HexDump.swift
  • TablePro/Models/Query/QueryResult.swift
  • TablePro/Views/AIChat/AIChatPanelView.swift
  • TablePro/Views/Connection/ConnectionFormView.swift
  • TablePro/Views/Editor/HistoryPanelView.swift
  • TablePro/Views/Editor/SQLEditorView.swift
  • TablePro/Views/Filter/FilterRowView.swift
  • TablePro/Views/Main/Child/MainEditorContentView.swift
  • TablePro/Views/QuickSwitcher/QuickSwitcherView.swift
  • TablePro/Views/Structure/ClickHousePartsView.swift
  • docs/development/performance-audit.md
💤 Files with no reviewable changes (1)
  • TablePro/Views/Filter/FilterRowView.swift
✅ Files skipped from review due to trivial changes (2)
  • TablePro/Views/Structure/ClickHousePartsView.swift
  • TablePro/Models/Query/QueryResult.swift

@datlechin datlechin merged commit 784f584 into main Mar 21, 2026
2 of 3 checks passed
@datlechin datlechin deleted the fix/high-cpu-energy-usage branch March 21, 2026 08:36
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.

High Energy Consumption and CPU Usage

1 participant