Skip to content

Commit 981e0b1

Browse files
committed
fix: reduce memory retention after closing tabs
- Clear changeManager state and pluginDriver reference in teardown - Cancel redisDatabaseSwitchTask in teardown - Clear cachedTableColumnTypes/Names, tableMetadata, filterState in teardown - Release editor closures and heavy state (tree-sitter, highlighter) on destroy - Add releaseHeavyState() to TextViewController for early resource cleanup - Make InMemoryRowProvider.rowBuffer weak with safe fallback - Add releaseData() to InMemoryRowProvider for explicit cleanup - Clear tabProviderCache, sortCache, cachedChangeManager in onTeardown - Hint malloc to return freed pages after disconnect - Add deinit logging for RowBuffer and QueryTabManager
1 parent d609e74 commit 981e0b1

10 files changed

Lines changed: 114 additions & 10 deletions

File tree

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,30 @@ public class TextViewController: NSViewController {
290290
self.gutterView.setNeedsDisplay(self.gutterView.frame)
291291
}
292292

293+
/// Release heavy resources (tree-sitter, highlighter, text storage) early,
294+
/// without waiting for deinit. Call when the editor is no longer visible but
295+
/// SwiftUI may keep the controller alive in @State.
296+
public func releaseHeavyState() {
297+
if let highlighter {
298+
textView?.removeStorageDelegate(highlighter)
299+
}
300+
highlighter = nil
301+
treeSitterClient = nil
302+
highlightProviders.removeAll()
303+
textCoordinators.values().forEach { $0.destroy() }
304+
textCoordinators.removeAll()
305+
cancellables.forEach { $0.cancel() }
306+
cancellables.removeAll()
307+
if let localEventMonitor {
308+
NSEvent.removeMonitor(localEventMonitor)
309+
}
310+
localEventMonitor = nil
311+
textView?.setText("")
312+
}
313+
293314
deinit {
294315
if let highlighter {
295-
textView.removeStorageDelegate(highlighter)
316+
textView?.removeStorageDelegate(highlighter)
296317
}
297318
highlighter = nil
298319
highlightProviders.removeAll()

TablePro/Models/Query/QueryTab.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import Observation
10+
import os
1011
import TableProPluginKit
1112

1213
/// Type of tab
@@ -269,6 +270,11 @@ final class RowBuffer {
269270
self.rows = newRows
270271
isEvicted = false
271272
}
273+
274+
deinit {
275+
Logger(subsystem: "com.TablePro", category: "RowBuffer")
276+
.debug("RowBuffer deallocated — columns: \(self.columns.count), evicted: \(self.isEvicted)")
277+
}
272278
}
273279

274280
/// Represents a single tab (query or table)
@@ -676,4 +682,9 @@ final class QueryTabManager {
676682
tabs[index] = tab
677683
}
678684
}
685+
686+
deinit {
687+
Logger(subsystem: "com.TablePro", category: "QueryTabManager")
688+
.debug("QueryTabManager deallocated")
689+
}
679690
}

TablePro/Models/Query/RowProvider.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ final class TableRowData {
6666
/// Direct-access methods `value(atRow:column:)` and `rowValues(at:)` avoid
6767
/// heap allocations by reading straight from the source `[String?]` array.
6868
final class InMemoryRowProvider: RowProvider {
69-
private let rowBuffer: RowBuffer
69+
private weak var rowBuffer: RowBuffer?
70+
/// Strong reference only when the provider created its own buffer (convenience init).
71+
/// External buffers are owned by QueryTab, so we hold them weakly.
72+
private var ownedBuffer: RowBuffer?
73+
private static let emptyBuffer = RowBuffer()
74+
private var safeBuffer: RowBuffer { rowBuffer ?? Self.emptyBuffer }
7075
private var sortIndices: [Int]?
7176
private var appendedRows: [[String?]] = []
7277
private(set) var columns: [String]
@@ -86,7 +91,7 @@ final class InMemoryRowProvider: RowProvider {
8691

8792
/// Number of rows coming from the buffer (respecting sort indices count when present)
8893
private var bufferRowCount: Int {
89-
sortIndices?.count ?? rowBuffer.rows.count
94+
sortIndices?.count ?? safeBuffer.rows.count
9095
}
9196

9297
init(
@@ -130,6 +135,7 @@ final class InMemoryRowProvider: RowProvider {
130135
columnEnumValues: columnEnumValues,
131136
columnNullable: columnNullable
132137
)
138+
ownedBuffer = buffer
133139
}
134140

135141
func fetchRows(offset: Int, limit: Int) -> [TableRowData] {
@@ -157,7 +163,7 @@ final class InMemoryRowProvider: RowProvider {
157163
guard rowIndex < totalRowCount else { return }
158164
let sourceIndex = resolveSourceIndex(rowIndex)
159165
if let bufferIdx = sourceIndex.bufferIndex {
160-
rowBuffer.rows[bufferIdx][columnIndex] = value
166+
safeBuffer.rows[bufferIdx][columnIndex] = value
161167
displayCache.removeValue(forKey: bufferIdx)
162168
} else if let appendedIdx = sourceIndex.appendedIndex {
163169
appendedRows[appendedIdx][columnIndex] = value
@@ -215,9 +221,17 @@ final class InMemoryRowProvider: RowProvider {
215221
displayCache.removeAll()
216222
}
217223

224+
/// Release cached data to free memory when this provider is no longer active.
225+
func releaseData() {
226+
displayCache.removeAll()
227+
appendedRows.removeAll()
228+
sortIndices = nil
229+
ownedBuffer = nil
230+
}
231+
218232
/// Update rows by replacing the buffer contents and clearing appended rows
219233
func updateRows(_ newRows: [[String?]]) {
220-
rowBuffer.rows = newRows
234+
safeBuffer.rows = newRows
221235
appendedRows.removeAll()
222236
sortIndices = nil
223237
displayCache.removeAll()
@@ -242,15 +256,15 @@ final class InMemoryRowProvider: RowProvider {
242256
} else {
243257
if let sorted = sortIndices {
244258
let bufferIdx = sorted[index]
245-
rowBuffer.rows.remove(at: bufferIdx)
259+
safeBuffer.rows.remove(at: bufferIdx)
246260
var newIndices = sorted
247261
newIndices.remove(at: index)
248262
for i in newIndices.indices where newIndices[i] > bufferIdx {
249263
newIndices[i] -= 1
250264
}
251265
sortIndices = newIndices
252266
} else {
253-
rowBuffer.rows.remove(at: index)
267+
safeBuffer.rows.remove(at: index)
254268
}
255269
}
256270
displayCache.removeAll()
@@ -297,9 +311,9 @@ final class InMemoryRowProvider: RowProvider {
297311
return appendedRows[displayIndex - bCount]
298312
}
299313
if let sorted = sortIndices {
300-
return rowBuffer.rows[sorted[displayIndex]]
314+
return safeBuffer.rows[sorted[displayIndex]]
301315
}
302-
return rowBuffer.rows[displayIndex]
316+
return safeBuffer.rows[displayIndex]
303317
}
304318
}
305319

TablePro/Resources/Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27181,6 +27181,9 @@
2718127181
}
2718227182
}
2718327183
}
27184+
},
27185+
"SSH Connection Test Failed" : {
27186+
2718427187
},
2718527188
"SSH connection timed out" : {
2718627189
"localizations" : {

TablePro/Views/Editor/SQLEditorCoordinator.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,22 @@ final class SQLEditorCoordinator: TextViewCoordinator {
146146
inlineSuggestionManager?.uninstall()
147147
inlineSuggestionManager = nil
148148

149+
// Release closure captures to break potential retain cycles
150+
onCloseTab = nil
151+
onExecuteQuery = nil
152+
onAIExplain = nil
153+
onAIOptimize = nil
154+
onSaveAsFavorite = nil
155+
schemaProvider = nil
156+
contextMenu = nil
157+
vimEngine = nil
158+
vimCursorManager = nil
159+
160+
// Release editor controller heavy state
161+
controller?.releaseHeavyState()
162+
149163
EditorEventRouter.shared.unregister(self)
164+
Self.logger.debug("SQLEditorCoordinator destroyed")
150165
cleanupMonitors()
151166
}
152167

TablePro/Views/Editor/SQLEditorView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ struct SQLEditorView: View {
126126
.onDisappear {
127127
teardownFavoritesObserver()
128128
coordinator.destroy()
129+
completionAdapter = nil
129130
}
130131
.onChange(of: coordinator.vimMode) { _, newMode in
131132
vimMode = newMode

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ struct MainEditorContentView: View {
147147
if let tab = tabManager.selectedTab {
148148
cacheRowProvider(for: tab)
149149
}
150+
coordinator.onTeardown = { [self] in
151+
tabProviderCache.removeAll()
152+
sortCache.removeAll()
153+
cachedChangeManager = nil
154+
}
150155
}
151156
.onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in
152157
guard let tab = tabManager.selectedTab, newVersion != nil else { return }

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ final class MainContentCoordinator {
125125
/// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`.
126126
@ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation<Bool, Never>?
127127

128+
/// Called during teardown to let the view layer release cached row providers and sort data.
129+
@ObservationIgnored var onTeardown: (() -> Void)?
130+
128131
/// True while a database switch is in progress. Guards against
129132
/// side-effect window creation during the switch cascade.
130133
var isSwitchingDatabase = false
@@ -334,6 +337,7 @@ final class MainContentCoordinator {
334337
/// synchronously on MainActor so we don't depend on deinit + Task scheduling.
335338
func teardown() {
336339
_didTeardown.withLock { $0 = true }
340+
337341
unregisterFromPersistence()
338342
for observer in urlFilterObservers {
339343
NotificationCenter.default.removeObserver(observer)
@@ -351,18 +355,38 @@ final class MainContentCoordinator {
351355
currentQueryTask = nil
352356
changeManagerUpdateTask?.cancel()
353357
changeManagerUpdateTask = nil
358+
redisDatabaseSwitchTask?.cancel()
359+
redisDatabaseSwitchTask = nil
354360
for task in activeSortTasks.values { task.cancel() }
355361
activeSortTasks.removeAll()
356362

363+
// Let the view layer release cached row providers before we drop RowBuffers.
364+
// Called synchronously here because SwiftUI onChange handlers don't fire
365+
// reliably on disappearing views.
366+
onTeardown?()
367+
onTeardown = nil
368+
357369
// Release heavy data so memory drops even if SwiftUI delays deallocation
358370
for tab in tabManager.tabs {
359371
tab.rowBuffer.evict()
360372
}
361373
querySortCache.removeAll()
374+
cachedTableColumnTypes.removeAll()
375+
cachedTableColumnNames.removeAll()
362376

363377
tabManager.tabs.removeAll()
364378
tabManager.selectedTabId = nil
365379

380+
// Release change manager state — pluginDriver holds a strong reference
381+
// to the entire database driver which prevents deallocation
382+
changeManager.clearChanges()
383+
changeManager.pluginDriver = nil
384+
385+
// Release metadata and filter state
386+
tableMetadata = nil
387+
filterStateManager.filters.removeAll()
388+
filterStateManager.appliedFilters.removeAll()
389+
366390
SchemaProviderRegistry.shared.release(for: connection.id)
367391
SchemaProviderRegistry.shared.purgeUnused()
368392
}

TablePro/Views/Main/MainContentView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ struct MainContentView: View {
272272
// If no more windows for this connection, disconnect.
273273
// Tab state is NOT cleared here — it's preserved for next reconnect.
274274
// Only handleTabsChange(count=0) clears state (user explicitly closed all tabs).
275-
guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { return }
275+
guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else {
276+
// Hint malloc to return freed pages to the OS
277+
malloc_zone_pressure_relief(nil, 0)
278+
return
279+
}
276280

277281
let hasVisibleWindow = NSApp.windows.contains { window in
278282
window.isVisible && (window.subtitle == connectionName
@@ -281,6 +285,11 @@ struct MainContentView: View {
281285
if !hasVisibleWindow {
282286
await DatabaseManager.shared.disconnectSession(connectionId)
283287
}
288+
289+
// Give SwiftUI/AppKit time to deallocate view hierarchies,
290+
// then hint malloc to return freed pages to the OS
291+
try? await Task.sleep(for: .seconds(2))
292+
malloc_zone_pressure_relief(nil, 0)
284293
}
285294
}
286295
.onChange(of: pendingChangeTrigger) {

TablePro/Views/Results/DataGridView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ struct DataGridView: NSViewRepresentable {
632632
NotificationCenter.default.removeObserver(observer)
633633
coordinator.themeObserver = nil
634634
}
635+
coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: [])
635636
}
636637

637638
func makeCoordinator() -> TableViewCoordinator {

0 commit comments

Comments
 (0)