Skip to content

Commit e2cc21a

Browse files
committed
Merge Wave 2 refactor: cross-window state, persistence integrity, lifecycle leak fixes
2 parents 3d67538 + f862399 commit e2cc21a

11 files changed

Lines changed: 164 additions & 77 deletions

TablePro/Core/Database/DatabaseManager+Health.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ extension DatabaseManager {
5151
reconnectHandler: { [weak self] in
5252
guard let self else { return false }
5353
guard let session = await self.activeSessions[connectionId] else { return false }
54+
await SchemaService.shared.invalidate(connectionId: connectionId)
5455
do {
5556
let result = try await self.trackOperation(sessionId: connectionId) {
5657
try await self.reconnectDriver(for: session)
@@ -207,6 +208,8 @@ extension DatabaseManager {
207208
session.status = .connecting
208209
}
209210

211+
await SchemaService.shared.invalidate(connectionId: sessionId)
212+
210213
// Stop existing health monitor
211214
await stopHealthMonitor(for: sessionId)
212215

TablePro/Core/Database/DatabaseManager+Sessions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ extension DatabaseManager {
247247
session.currentSchema = nil
248248
}
249249
appSettingsStorage.saveLastSchema(nil, for: connectionId)
250+
await SchemaService.shared.invalidate(connectionId: connectionId)
250251
await reconnectSession(connectionId)
251252
} else if pm?.capabilities.supportsSchemaSwitching == true,
252253
let schemaDriver = driver as? SchemaSwitchable {

TablePro/Core/MCP/MCPServer.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,21 @@ actor MCPServer {
132132
cleanupTask?.cancel()
133133
cleanupTask = nil
134134

135+
let sessionIds = Array(sessions.keys)
135136
for (_, session) in sessions {
136137
await session.cancelAllTasks()
137138
await session.cancelSSEConnection()
138139
}
139140
sessions.removeAll()
140141

142+
// Run the per-session cleanup handler so policy-side state
143+
// (e.g. MCPAuthPolicy.sessionApprovals) doesn't leak across server restarts.
144+
if let cleanupHandler = sessionCleanupHandler {
145+
for id in sessionIds {
146+
await cleanupHandler(id)
147+
}
148+
}
149+
141150
if let currentListener = listener {
142151
listener = nil
143152
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in

TablePro/Core/Services/Infrastructure/SessionStateFactory.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,28 @@ enum SessionStateFactory {
3030
/// only one coordinator exists per window — no duplicate-tab side effects.
3131
private static var pendingSessionStates: [UUID: SessionState] = [:]
3232

33+
/// Pending entries that aren't claimed by a `ContentView.init` within this TTL
34+
/// are dropped to prevent leaks if the window-open path errors out before the
35+
/// hand-off completes.
36+
private static let pendingEntryTTL: Duration = .seconds(5)
37+
3338
static func registerPending(_ state: SessionState, for payloadId: UUID) {
3439
pendingSessionStates[payloadId] = state
40+
Task { [payloadId] in
41+
try? await Task.sleep(for: pendingEntryTTL)
42+
await MainActor.run {
43+
guard let abandoned = pendingSessionStates.removeValue(forKey: payloadId) else {
44+
return
45+
}
46+
// Coordinator was eagerly registered with `activeCoordinators`
47+
// by `create(...)`. If no `ContentView.init` consumed the pending
48+
// entry within the TTL, the window-open path never completed —
49+
// unregister so the coordinator can deallocate.
50+
MainContentCoordinator.activeCoordinators.removeValue(
51+
forKey: abandoned.coordinator.instanceId
52+
)
53+
}
54+
}
3555
}
3656

3757
static func consumePending(for payloadId: UUID) -> SessionState? {
@@ -158,6 +178,13 @@ enum SessionStateFactory {
158178
toolbarState: toolbarSt
159179
)
160180

181+
// Eagerly publish to the active-coordinator registry so concurrent
182+
// window opens for the same connection both observe each other when
183+
// computing globals like nextQueryTitle. Without this, two windows
184+
// opened back-to-back can both compute "Query 1" before either has
185+
// run onAppear.
186+
coord.registerEagerly()
187+
161188
return SessionState(
162189
tabManager: tabMgr,
163190
changeManager: changeMgr,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// TabPersistenceCoordinator+AggregatedSave.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
extension TabPersistenceCoordinator {
9+
/// Save or clear persisted state based on tabs aggregated across all windows
10+
/// for the connection. Prevents the per-window close path from clobbering
11+
/// state when sibling windows still have open tabs.
12+
func saveOrClearAggregated() {
13+
let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId)
14+
if aggregatedTabs.isEmpty {
15+
clearSavedState()
16+
} else {
17+
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
18+
saveNow(tabs: aggregatedTabs, selectedTabId: selectedId)
19+
}
20+
}
21+
22+
/// Synchronous variant for the window-close path, where the run loop may
23+
/// not be available to service Tasks before the window tears down.
24+
func saveOrClearAggregatedSync() {
25+
let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId)
26+
if aggregatedTabs.isEmpty {
27+
saveNowSync(tabs: [], selectedTabId: nil)
28+
} else {
29+
let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId)
30+
saveNowSync(tabs: aggregatedTabs, selectedTabId: selectedId)
31+
}
32+
}
33+
}

TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ internal final class TabPersistenceCoordinator {
3030
private static let logger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle")
3131
let connectionId: UUID
3232

33+
@ObservationIgnored private var saveTask: Task<Void, Never>?
34+
3335
init(connectionId: UUID) {
3436
self.connectionId = connectionId
3537
}
@@ -45,43 +47,25 @@ internal final class TabPersistenceCoordinator {
4547
return
4648
}
4749
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
48-
let connId = connectionId
4950
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
5051
? selectedTabId : nonPreviewTabs.first?.id
51-
Self.logger.debug("[persist] saveNow queued tabCount=\(nonPreviewTabs.count) connId=\(connId, privacy: .public)")
52-
53-
Task {
54-
let t0 = Date()
55-
do {
56-
try await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId)
57-
Self.logger.debug("[persist] saveNow written tabCount=\(persisted.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
58-
} catch {
59-
TabDiskActor.logSaveError(connectionId: connId, error: error)
60-
}
61-
}
52+
scheduleSave(tabs: persisted, selectedTabId: normalizedSelectedId)
6253
}
6354

6455
/// Save pre-aggregated tabs for the quit path, where the caller has already
6556
/// collected and converted tabs from all windows for this connection.
6657
internal func saveNow(persistedTabs: [PersistedTab], selectedTabId: UUID?) {
67-
let connId = connectionId
68-
let selectedId = selectedTabId
69-
70-
Task {
71-
do {
72-
try await TabDiskActor.shared.save(connectionId: connId, tabs: persistedTabs, selectedTabId: selectedId)
73-
} catch {
74-
TabDiskActor.logSaveError(connectionId: connId, error: error)
75-
}
76-
}
58+
scheduleSave(tabs: persistedTabs, selectedTabId: selectedTabId)
7759
}
7860

7961
/// Synchronous save for `applicationWillTerminate` where no run loop
8062
/// remains to service async Tasks. Bypasses the actor and writes directly.
8163
internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) {
8264
let nonPreviewTabs = tabs.filter { !$0.isPreview }
8365
guard !nonPreviewTabs.isEmpty else {
84-
TabDiskActor.saveSync(connectionId: connectionId, tabs: [], selectedTabId: nil)
66+
saveTask?.cancel()
67+
saveTask = nil
68+
TabDiskActor.clearSync(connectionId: connectionId)
8569
return
8670
}
8771
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
@@ -94,12 +78,37 @@ internal final class TabPersistenceCoordinator {
9478

9579
/// Clear all saved state for this connection (user closed all tabs).
9680
internal func clearSavedState() {
81+
saveTask?.cancel()
82+
saveTask = nil
9783
let connId = connectionId
9884
Task {
9985
await TabDiskActor.shared.clear(connectionId: connId)
10086
}
10187
}
10288

89+
// MARK: - Private save scheduling
90+
91+
private func scheduleSave(tabs: [PersistedTab], selectedTabId: UUID?) {
92+
saveTask?.cancel()
93+
let connId = connectionId
94+
let tabsCopy = tabs
95+
let selectedId = selectedTabId
96+
Self.logger.debug("[persist] saveNow queued tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public)")
97+
98+
saveTask = Task {
99+
guard !Task.isCancelled else { return }
100+
let t0 = Date()
101+
do {
102+
try await TabDiskActor.shared.save(connectionId: connId, tabs: tabsCopy, selectedTabId: selectedId)
103+
Self.logger.debug("[persist] saveNow written tabCount=\(tabsCopy.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))")
104+
} catch is CancellationError {
105+
return
106+
} catch {
107+
Self.logger.fault("Failed to save tab state for connection \(connId, privacy: .public): \(error.localizedDescription, privacy: .public)")
108+
}
109+
}
110+
}
111+
103112
// MARK: - Restore
104113

105114
/// Restore tabs from disk. Called once at window creation.

TablePro/Core/Storage/TabDiskActor.swift

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,6 @@ internal actor TabDiskActor {
6060
try data.write(to: fileURL, options: .atomic)
6161
}
6262

63-
/// Log a save error from callers that handle errors externally.
64-
nonisolated static func logSaveError(connectionId: UUID, error: Error) {
65-
logger.error("Failed to save tab state for \(connectionId): \(error.localizedDescription)")
66-
}
67-
6863
/// Load tab state for a connection. Returns nil if the file is missing or corrupt.
6964
internal func load(connectionId: UUID) -> TabDiskState? {
7065
let fileURL = tabStateFileURL(for: connectionId)
@@ -96,6 +91,8 @@ internal actor TabDiskActor {
9691
}
9792

9893
/// List all connection IDs that have saved tab state on disk.
94+
/// Self-cleans legacy empty-payload files: if a file decodes with no tabs,
95+
/// it is deleted and its connection ID is excluded from the result.
9996
internal func connectionIdsWithSavedState() -> [UUID] {
10097
let fm = FileManager.default
10198
guard let files = try? fm.contentsOfDirectory(
@@ -104,10 +101,18 @@ internal actor TabDiskActor {
104101
) else {
105102
return []
106103
}
107-
return files.compactMap { url -> UUID? in
108-
guard url.pathExtension == "json" else { return nil }
109-
return UUID(uuidString: url.deletingPathExtension().lastPathComponent)
104+
var validIds: [UUID] = []
105+
for url in files where url.pathExtension == "json" {
106+
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { continue }
107+
if let data = try? Data(contentsOf: url),
108+
let state = try? decoder.decode(TabDiskState.self, from: data),
109+
!state.tabs.isEmpty {
110+
validIds.append(id)
111+
} else {
112+
try? fm.removeItem(at: url)
113+
}
110114
}
115+
return validIds
111116
}
112117

113118
// MARK: - Static Path Helpers
@@ -145,7 +150,20 @@ internal actor TabDiskActor {
145150
let fileURL = tabStateFileURL(for: connectionId)
146151
try data.write(to: fileURL, options: .atomic)
147152
} catch {
148-
logger.error("saveSync failed for \(connectionId): \(error.localizedDescription)")
153+
logger.fault("saveSync failed for \(connectionId): \(error.localizedDescription)")
154+
}
155+
}
156+
157+
/// Synchronous clear for `applicationWillTerminate`, where no run loop
158+
/// remains to execute an async Task. Mirrors `saveSync` — deletes the
159+
/// connection's tab state file directly.
160+
nonisolated internal static func clearSync(connectionId: UUID) {
161+
let fileURL = tabStateFileURL(for: connectionId)
162+
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
163+
do {
164+
try FileManager.default.removeItem(at: fileURL)
165+
} catch {
166+
logger.fault("clearSync failed for \(connectionId): \(error.localizedDescription)")
149167
}
150168
}
151169

TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,11 @@ extension MainContentCoordinator {
102102
"[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)"
103103
)
104104

105-
// Persist remaining non-preview tabs synchronously. saveNowSync writes
106-
// directly without spawning a Task — required here because the window
107-
// is closing and we cannot rely on async tasks being serviced.
108-
let persistableTabs = tabManager.tabs.filter { !$0.isPreview }
109-
if persistableTabs.isEmpty {
110-
// Empty → clear saved state so next open shows a default empty window.
111-
persistence.saveNowSync(tabs: [], selectedTabId: nil)
112-
} else {
113-
let normalizedSelectedId =
114-
persistableTabs.contains(where: { $0.id == tabManager.selectedTabId })
115-
? tabManager.selectedTabId : persistableTabs.first?.id
116-
persistence.saveNowSync(tabs: persistableTabs, selectedTabId: normalizedSelectedId)
117-
}
105+
// Persist tabs aggregated across all windows for this connection.
106+
// Writing this window's tabs in isolation can clobber sibling windows'
107+
// state on disk — for example, closing an empty window would erase the
108+
// saved tabs of an open sibling window.
109+
persistence.saveOrClearAggregatedSync()
118110

119111
// Cancel the pending eviction task before teardown drops it.
120112
evictionTask?.cancel()

TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,9 @@ extension MainContentView {
5858
coordinator.promotePreviewTab()
5959
}
6060

61-
let persistableTabs = tabManager.tabs.filter { !$0.isPreview }
62-
if persistableTabs.isEmpty {
63-
coordinator.persistence.clearSavedState()
64-
} else {
65-
let normalizedSelectedId =
66-
persistableTabs.contains(where: { $0.id == tabManager.selectedTabId })
67-
? tabManager.selectedTabId : persistableTabs.first?.id
68-
coordinator.persistence.saveNow(
69-
tabs: persistableTabs,
70-
selectedTabId: normalizedSelectedId
71-
)
72-
}
61+
coordinator.persistence.saveOrClearAggregated()
7362
MainContentView.lifecycleLogger.debug(
74-
"[switch] handleStructureChange tabCount=\(tabManager.tabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))"
63+
"[switch] handleStructureChange tabCount=\(tabManager.tabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))"
7564
)
7665
}
7766

TablePro/Views/Main/Extensions/MainContentView+Setup.swift

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,13 @@ extension MainContentView {
137137

138138
if !remainingTabs.isEmpty {
139139
let selectedWasFirst = firstTab.id == selectedId
140-
Task { @MainActor in
141-
for tab in remainingTabs {
142-
let restorePayload = EditorTabPayload(
143-
from: tab, connectionId: connection.id, skipAutoExecute: true)
144-
WindowManager.shared.openTab(payload: restorePayload)
145-
}
146-
// Bring the first window to front only if it had the selected tab.
147-
// Otherwise let the last restored window stay focused.
148-
if selectedWasFirst {
149-
viewWindow?.makeKeyAndOrderFront(nil)
150-
}
140+
for tab in remainingTabs {
141+
let restorePayload = EditorTabPayload(
142+
from: tab, connectionId: connection.id, skipAutoExecute: true)
143+
WindowManager.shared.openTab(payload: restorePayload)
144+
}
145+
if selectedWasFirst {
146+
viewWindow?.makeKeyAndOrderFront(nil)
151147
}
152148
}
153149

0 commit comments

Comments
 (0)