Skip to content

Commit 784f584

Browse files
authored
fix: reduce idle CPU usage from 79% to near zero (#395)
* fix: reduce idle CPU usage from 79% to near zero (#394) * perf: fix 15 performance bottlenecks from full codebase audit * docs: remove performance audit tracking file * fix: address PR review findings from CodeRabbit
1 parent fbf0bc9 commit 784f584

24 files changed

Lines changed: 285 additions & 178 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Fixed
1717

18+
- High CPU usage (79%+) and energy consumption when idle (#394)
1819
- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`)
1920
- Data grid editing (delete rows, modify cells, add rows) not working in query tabs (#383)
2021

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public final class SuggestionController: NSWindowController {
3737
/// Holds the observer for the window resign notifications
3838
private var windowResignObserver: NSObjectProtocol?
3939
/// Closes autocomplete when first responder changes away from the active text view
40-
private var firstResponderObserver: NSObjectProtocol?
40+
private var firstResponderKVO: NSKeyValueObservation?
4141
private var localEventMonitor: Any?
4242
private var sizeObservers: Set<AnyCancellable> = []
4343

@@ -136,26 +136,22 @@ public final class SuggestionController: NSWindowController {
136136
self?.close()
137137
}
138138

139-
// Close when the active text view is removed (e.g., tab closed/switched)
140-
if let existingObserver = firstResponderObserver {
141-
NotificationCenter.default.removeObserver(existingObserver)
142-
}
143-
firstResponderObserver = NotificationCenter.default.addObserver(
144-
forName: NSWindow.didUpdateNotification,
145-
object: parentWindow,
146-
queue: .main
147-
) { [weak self] _ in
148-
guard let self else { return }
149-
guard let textView = self.model.activeTextView else {
150-
self.close()
151-
return
152-
}
153-
// Close if text view removed from window or lost first responder
154-
if textView.view.window == nil {
155-
self.close()
156-
} else if let firstResponder = textView.view.window?.firstResponder as? NSView,
157-
!firstResponder.isDescendant(of: textView.view) {
158-
self.close()
139+
// Close when first responder changes away from the active text view
140+
firstResponderKVO?.invalidate()
141+
firstResponderKVO = parentWindow.observe(\.firstResponder, options: [.new]) { [weak self] window, _ in
142+
DispatchQueue.main.async { [weak self] in
143+
guard let self else { return }
144+
guard let textView = self.model.activeTextView else {
145+
self.close()
146+
return
147+
}
148+
if textView.view.window == nil {
149+
self.close()
150+
} else if textView.view.window === window,
151+
let firstResponder = window.firstResponder as? NSView,
152+
!firstResponder.isDescendant(of: textView.view) {
153+
self.close()
154+
}
159155
}
160156
}
161157

@@ -174,10 +170,8 @@ public final class SuggestionController: NSWindowController {
174170
windowResignObserver = nil
175171
}
176172

177-
if let observer = firstResponderObserver {
178-
NotificationCenter.default.removeObserver(observer)
179-
firstResponderObserver = nil
180-
}
173+
firstResponderKVO?.invalidate()
174+
firstResponderKVO = nil
181175

182176
if popover != nil {
183177
popover?.close()

TablePro/ContentView.swift

Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ struct ContentView: View {
1818
let payload: EditorTabPayload?
1919

2020
@State private var currentSession: ConnectionSession?
21+
@State private var closingSessionId: UUID?
2122
@State private var columnVisibility: NavigationSplitViewVisibility = .all
2223
@State private var showNewConnectionSheet = false
2324
@State private var showEditConnectionSheet = false
@@ -70,6 +71,7 @@ struct ContentView: View {
7071
// Right sidebar toggle is handled by MainContentView (has the binding)
7172
// Left sidebar toggle uses native NSSplitViewController.toggleSidebar via responder chain
7273
.onChange(of: DatabaseManager.shared.currentSessionId, initial: true) { _, newSessionId in
74+
guard closingSessionId == nil else { return }
7375
let ourConnectionId = payload?.connectionId
7476
if ourConnectionId != nil {
7577
guard newSessionId == ourConnectionId else { return }
@@ -102,59 +104,9 @@ struct ContentView: View {
102104
columnVisibility = .detailOnly
103105
}
104106
}
105-
.onChange(of: (payload?.connectionId ?? currentSession?.id).flatMap { DatabaseManager.shared.connectionStatusVersions[$0] }, initial: true) { _, _ in
106-
let sessions = DatabaseManager.shared.activeSessions
107-
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
108-
guard let sid = connectionId else {
109-
if currentSession != nil { currentSession = nil }
110-
return
111-
}
112-
guard let newSession = sessions[sid] else {
113-
if currentSession?.id == sid {
114-
rightPanelState?.teardown()
115-
rightPanelState = nil
116-
sessionState?.coordinator.teardown()
117-
sessionState = nil
118-
currentSession = nil
119-
columnVisibility = .detailOnly
120-
AppState.shared.isConnected = false
121-
AppState.shared.safeModeLevel = .silent
122-
AppState.shared.editorLanguage = .sql
123-
AppState.shared.currentDatabaseType = nil
124-
AppState.shared.supportsDatabaseSwitching = true
125-
126-
// Close all native tab windows for this connection and
127-
// force AppKit to deallocate them instead of pooling.
128-
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
129-
DispatchQueue.main.async {
130-
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
131-
window.isReleasedWhenClosed = true
132-
window.close()
133-
}
134-
}
135-
}
136-
return
137-
}
138-
if let existing = currentSession,
139-
existing.isContentViewEquivalent(to: newSession) {
140-
return
141-
}
142-
currentSession = newSession
143-
if rightPanelState == nil {
144-
rightPanelState = RightPanelState()
145-
}
146-
if sessionState == nil {
147-
sessionState = SessionStateFactory.create(
148-
connection: newSession.connection,
149-
payload: payload
150-
)
151-
}
152-
AppState.shared.isConnected = true
153-
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
154-
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
155-
AppState.shared.currentDatabaseType = newSession.connection.type
156-
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
157-
for: newSession.connection.type)
107+
.task { handleConnectionStatusChange() }
108+
.onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in
109+
handleConnectionStatusChange()
158110
}
159111
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
160112
// Only process notifications for our own window to avoid every
@@ -378,6 +330,63 @@ struct ContentView: View {
378330
)
379331
}
380332

333+
// MARK: - Connection Status
334+
335+
private func handleConnectionStatusChange() {
336+
guard closingSessionId == nil else { return }
337+
let sessions = DatabaseManager.shared.activeSessions
338+
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
339+
guard let sid = connectionId else {
340+
if currentSession != nil { currentSession = nil }
341+
return
342+
}
343+
guard let newSession = sessions[sid] else {
344+
if currentSession?.id == sid {
345+
closingSessionId = sid
346+
rightPanelState?.teardown()
347+
rightPanelState = nil
348+
sessionState?.coordinator.teardown()
349+
sessionState = nil
350+
currentSession = nil
351+
columnVisibility = .detailOnly
352+
AppState.shared.isConnected = false
353+
AppState.shared.safeModeLevel = .silent
354+
AppState.shared.editorLanguage = .sql
355+
AppState.shared.currentDatabaseType = nil
356+
AppState.shared.supportsDatabaseSwitching = true
357+
358+
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
359+
DispatchQueue.main.async {
360+
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
361+
window.isReleasedWhenClosed = true
362+
window.close()
363+
}
364+
}
365+
}
366+
return
367+
}
368+
if let existing = currentSession,
369+
existing.isContentViewEquivalent(to: newSession) {
370+
return
371+
}
372+
currentSession = newSession
373+
if rightPanelState == nil {
374+
rightPanelState = RightPanelState()
375+
}
376+
if sessionState == nil {
377+
sessionState = SessionStateFactory.create(
378+
connection: newSession.connection,
379+
payload: payload
380+
)
381+
}
382+
AppState.shared.isConnected = true
383+
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
384+
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
385+
AppState.shared.currentDatabaseType = newSession.connection.type
386+
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
387+
for: newSession.connection.type)
388+
}
389+
381390
// MARK: - Actions
382391

383392
private func connectToDatabase(_ connection: DatabaseConnection) {

TablePro/Core/AI/InlineSuggestionManager.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ final class InlineSuggestionManager {
5858
func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) {
5959
self.controller = controller
6060
self.schemaProvider = schemaProvider
61-
installScrollObserver()
6261
}
6362

6463
func editorDidFocus() {
@@ -87,11 +86,7 @@ final class InlineSuggestionManager {
8786
removeGhostLayer()
8887

8988
removeKeyEventMonitor()
90-
91-
if let observer = _scrollObserver.withLock({ $0 }) {
92-
NotificationCenter.default.removeObserver(observer)
93-
_scrollObserver.withLock { $0 = nil }
94-
}
89+
removeScrollObserver()
9590

9691
schemaProvider = nil
9792
controller = nil
@@ -337,6 +332,7 @@ final class InlineSuggestionManager {
337332

338333
textView.layer?.addSublayer(layer)
339334
ghostLayer = layer
335+
installScrollObserver()
340336
}
341337

342338
private func removeGhostLayer() {
@@ -346,28 +342,28 @@ final class InlineSuggestionManager {
346342

347343
// MARK: - Accept / Dismiss
348344

349-
/// Accept the current suggestion by inserting it at the cursor
350345
private func acceptSuggestion() {
351346
guard let suggestion = currentSuggestion,
352347
let textView = controller?.textView else { return }
353348

354349
let offset = suggestionOffset
355350
removeGhostLayer()
356351
currentSuggestion = nil
352+
removeScrollObserver()
357353

358354
textView.replaceCharacters(
359355
in: NSRange(location: offset, length: 0),
360356
with: suggestion
361357
)
362358
}
363359

364-
/// Dismiss the current suggestion without inserting
365360
func dismissSuggestion() {
366361
debounceTimer?.invalidate()
367362
currentTask?.cancel()
368363
currentTask = nil
369364
removeGhostLayer()
370365
currentSuggestion = nil
366+
removeScrollObserver()
371367
}
372368

373369
// MARK: - Key Event Monitor
@@ -421,6 +417,7 @@ final class InlineSuggestionManager {
421417
// MARK: - Scroll Observer
422418

423419
private func installScrollObserver() {
420+
guard _scrollObserver.withLock({ $0 }) == nil else { return }
424421
guard let scrollView = controller?.scrollView else { return }
425422
let contentView = scrollView.contentView
426423

@@ -430,15 +427,22 @@ final class InlineSuggestionManager {
430427
object: contentView,
431428
queue: .main
432429
) { [weak self] _ in
433-
guard self?.currentSuggestion != nil else { return }
434430
Task { @MainActor [weak self] in
435431
guard let self else { return }
436432
if let suggestion = self.currentSuggestion {
437-
// Reposition the ghost layer after scroll
438433
self.showGhostText(suggestion, at: self.suggestionOffset)
439434
}
440435
}
441436
}
442437
}
443438
}
439+
440+
private func removeScrollObserver() {
441+
_scrollObserver.withLock {
442+
if let observer = $0 {
443+
NotificationCenter.default.removeObserver(observer)
444+
}
445+
$0 = nil
446+
}
447+
}
444448
}

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,14 @@ final class DatabaseManager {
312312
private func setSession(_ session: ConnectionSession, for connectionId: UUID) {
313313
activeSessions[connectionId] = session
314314
connectionStatusVersions[connectionId, default: 0] &+= 1
315+
NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId)
315316
}
316317

317318
/// Remove a session and clean up its per-connection version counter.
318319
private func removeSessionEntry(for connectionId: UUID) {
319320
activeSessions.removeValue(forKey: connectionId)
320321
connectionStatusVersions.removeValue(forKey: connectionId)
322+
NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId)
321323
}
322324

323325
#if DEBUG

TablePro/Core/Services/Formatting/SQLFormatterService.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,13 @@ struct SQLFormatterService: SQLFormatterProtocol {
108108
/// Get or create the keyword uppercasing regex for a given database type
109109
private static func keywordRegex(for dialect: DatabaseType) -> NSRegularExpression? {
110110
keywordRegexLock.lock()
111-
defer { keywordRegexLock.unlock() }
112-
113111
if let cached = keywordRegexCache[dialect] {
112+
keywordRegexLock.unlock()
114113
return cached
115114
}
115+
keywordRegexLock.unlock()
116116

117-
let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
117+
let provider = resolveDialectProvider(for: dialect)
118118
let allKeywords = provider.keywords.union(provider.functions).union(provider.dataTypes)
119119
let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) }
120120
let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b"
@@ -123,10 +123,24 @@ struct SQLFormatterService: SQLFormatterProtocol {
123123
return nil
124124
}
125125

126+
keywordRegexLock.lock()
127+
defer { keywordRegexLock.unlock() }
128+
if let cached = keywordRegexCache[dialect] {
129+
return cached
130+
}
126131
keywordRegexCache[dialect] = regex
127132
return regex
128133
}
129134

135+
private static func resolveDialectProvider(for dialect: DatabaseType) -> SQLDialectProvider {
136+
if Thread.isMainThread {
137+
return MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
138+
}
139+
return DispatchQueue.main.sync {
140+
MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
141+
}
142+
}
143+
130144
// MARK: - Public API
131145

132146
func format(
@@ -152,8 +166,7 @@ struct SQLFormatterService: SQLFormatterProtocol {
152166
throw SQLFormatterError.invalidCursorPosition(cursor, max: sqlLength)
153167
}
154168

155-
// Get dialect provider
156-
let dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
169+
let dialectProvider = Self.resolveDialectProvider(for: dialect)
157170

158171
// Format the SQL
159172
let formatted = formatSQL(sql, dialect: dialectProvider, databaseType: dialect, options: options)

TablePro/Core/Services/Infrastructure/AnalyticsService.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ final class AnalyticsService {
6969
while !Task.isCancelled {
7070
await self?.sendHeartbeat()
7171
try? await Task.sleep(for: .seconds(self?.heartbeatInterval ?? 86_400))
72+
guard self != nil else { return }
7273
}
7374
}
7475
}

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ extension Notification.Name {
1717
// MARK: - Connections
1818

1919
static let connectionUpdated = Notification.Name("connectionUpdated")
20+
static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange")
2021
static let databaseDidConnect = Notification.Name("databaseDidConnect")
2122

2223
// MARK: - SQL Favorites

TablePro/Core/Services/Licensing/LicenseManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ final class LicenseManager {
9494

9595
while !Task.isCancelled {
9696
try? await Task.sleep(for: .seconds(self?.revalidationInterval ?? 604_800))
97+
guard self != nil else { return }
9798
await self?.revalidate()
9899
}
99100
}

0 commit comments

Comments
 (0)