Skip to content

Commit 38e46b8

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 38e46b8

10 files changed

Lines changed: 178 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ enum ActiveSheet: Identifiable {
4646
final class MainContentCoordinator {
4747
static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator")
4848

49+
/// Posted during teardown so DataGridView coordinators can release cell views.
50+
/// Object is the connection UUID.
51+
static let teardownNotification = Notification.Name("MainContentCoordinator.teardown")
52+
4953
// MARK: - Dependencies
5054

5155
let connection: DatabaseConnection
@@ -125,6 +129,9 @@ final class MainContentCoordinator {
125129
/// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`.
126130
@ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation<Bool, Never>?
127131

132+
/// Called during teardown to let the view layer release cached row providers and sort data.
133+
@ObservationIgnored var onTeardown: (() -> Void)?
134+
128135
/// True while a database switch is in progress. Guards against
129136
/// side-effect window creation during the switch cascade.
130137
var isSwitchingDatabase = false
@@ -334,6 +341,7 @@ final class MainContentCoordinator {
334341
/// synchronously on MainActor so we don't depend on deinit + Task scheduling.
335342
func teardown() {
336343
_didTeardown.withLock { $0 = true }
344+
337345
unregisterFromPersistence()
338346
for observer in urlFilterObservers {
339347
NotificationCenter.default.removeObserver(observer)
@@ -351,18 +359,44 @@ final class MainContentCoordinator {
351359
currentQueryTask = nil
352360
changeManagerUpdateTask?.cancel()
353361
changeManagerUpdateTask = nil
362+
redisDatabaseSwitchTask?.cancel()
363+
redisDatabaseSwitchTask = nil
354364
for task in activeSortTasks.values { task.cancel() }
355365
activeSortTasks.removeAll()
356366

367+
// Let the view layer release cached row providers before we drop RowBuffers.
368+
// Called synchronously here because SwiftUI onChange handlers don't fire
369+
// reliably on disappearing views.
370+
onTeardown?()
371+
onTeardown = nil
372+
373+
// Notify DataGridView coordinators to release NSTableView cell views
374+
NotificationCenter.default.post(
375+
name: Self.teardownNotification,
376+
object: connection.id
377+
)
378+
357379
// Release heavy data so memory drops even if SwiftUI delays deallocation
358380
for tab in tabManager.tabs {
359381
tab.rowBuffer.evict()
360382
}
361383
querySortCache.removeAll()
384+
cachedTableColumnTypes.removeAll()
385+
cachedTableColumnNames.removeAll()
362386

363387
tabManager.tabs.removeAll()
364388
tabManager.selectedTabId = nil
365389

390+
// Release change manager state — pluginDriver holds a strong reference
391+
// to the entire database driver which prevents deallocation
392+
changeManager.clearChanges()
393+
changeManager.pluginDriver = nil
394+
395+
// Release metadata and filter state
396+
tableMetadata = nil
397+
filterStateManager.filters.removeAll()
398+
filterStateManager.appliedFilters.removeAll()
399+
366400
SchemaProviderRegistry.shared.release(for: connection.id)
367401
SchemaProviderRegistry.shared.purgeUnused()
368402
}

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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ struct DataGridView: NSViewRepresentable {
166166

167167
scrollView.documentView = tableView
168168
context.coordinator.tableView = tableView
169+
if let connectionId {
170+
context.coordinator.observeTeardown(connectionId: connectionId)
171+
}
169172

170173
return scrollView
171174
}
@@ -632,6 +635,7 @@ struct DataGridView: NSViewRepresentable {
632635
NotificationCenter.default.removeObserver(observer)
633636
coordinator.themeObserver = nil
634637
}
638+
coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: [])
635639
}
636640

637641
func makeCoordinator() -> TableViewCoordinator {
@@ -839,13 +843,64 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
839843
}
840844
}
841845

846+
/// Subscribe to coordinator teardown to release NSTableView cell views.
847+
func observeTeardown(connectionId: UUID) {
848+
teardownObserver = NotificationCenter.default.addObserver(
849+
forName: MainContentCoordinator.teardownNotification,
850+
object: connectionId,
851+
queue: nil
852+
) { [weak self] _ in
853+
guard let self else { return }
854+
MainActor.assumeIsolated {
855+
self.releaseData()
856+
}
857+
}
858+
}
859+
860+
/// Release all data and cell views from the NSTableView.
861+
/// Called during coordinator teardown to free memory while SwiftUI holds the view.
862+
private func releaseData() {
863+
overlayEditor?.dismiss(commit: false)
864+
rowProvider = InMemoryRowProvider(rows: [], columns: [])
865+
rowVisualStateCache.removeAll()
866+
cachedRowCount = 0
867+
cachedColumnCount = 0
868+
// Remove columns and reload to release cell views
869+
if let tableView {
870+
while let col = tableView.tableColumns.last {
871+
tableView.removeTableColumn(col)
872+
}
873+
tableView.reloadData()
874+
}
875+
// Release closures
876+
onRefresh = nil
877+
onCellEdit = nil
878+
onDeleteRows = nil
879+
onCopyRows = nil
880+
onPasteRows = nil
881+
onUndo = nil
882+
onRedo = nil
883+
onSort = nil
884+
onAddRow = nil
885+
onUndoInsert = nil
886+
onFilterColumn = nil
887+
onHideColumn = nil
888+
onNavigateFK = nil
889+
getVisualState = nil
890+
}
891+
892+
private var teardownObserver: NSObjectProtocol?
893+
842894
deinit {
843895
if let observer = settingsObserver {
844896
NotificationCenter.default.removeObserver(observer)
845897
}
846898
if let observer = themeObserver {
847899
NotificationCenter.default.removeObserver(observer)
848900
}
901+
if let observer = teardownObserver {
902+
NotificationCenter.default.removeObserver(observer)
903+
}
849904
}
850905

851906
func updateCache() {

0 commit comments

Comments
 (0)