diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index cff4cdee..1e8c0a17 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -34,6 +34,8 @@ final class DatabaseManager { /// Separate from the main driver so pings never queue behind long-running user queries. private var pingDrivers: [UUID: DatabaseDriver] = [:] + private var metadataCreationTasks: [UUID: Task] = [:] + /// Current session (computed from currentSessionId) var currentSession: ConnectionSession? { guard let sessionId = currentSessionId else { return nil } @@ -204,8 +206,9 @@ final class DatabaseManager { let metaConnection = effectiveConnection let metaConnectionId = connection.id let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds - Task { [weak self] in + metadataCreationTasks[metaConnectionId] = Task { [weak self] in guard let self else { return } + defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) } do { let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection) try await metaDriver.connect() @@ -269,6 +272,10 @@ final class DatabaseManager { try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) } + // Cancel any in-flight metadata driver creation + metadataCreationTasks[sessionId]?.cancel() + metadataCreationTasks.removeValue(forKey: sessionId) + // Stop health monitoring await stopHealthMonitor(for: sessionId) @@ -301,6 +308,9 @@ final class DatabaseManager { await stopHealthMonitor(for: sessionId) } + for task in metadataCreationTasks.values { task.cancel() } + metadataCreationTasks.removeAll() + let sessionIds = Array(activeSessions.keys) for sessionId in sessionIds { await disconnectSession(sessionId) @@ -539,7 +549,7 @@ final class DatabaseManager { } case .failed: Self.logger.error( - "Health monitoring failed for session \(id) after 3 retries") + "Health monitoring failed for session \(id)") self.updateSession(id) { session in session.status = .error(String(localized: "Connection lost")) session.clearCachedData() @@ -674,8 +684,9 @@ final class DatabaseManager { let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds let startupCmds = session.connection.startupCommands let connName = session.connection.name - Task { [weak self] in + metadataCreationTasks[metaConnectionId] = Task { [weak self] in guard let self else { return } + defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) } do { let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection) try await metaDriver.connect() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift index 85606969..16f0bed7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift @@ -6,9 +6,9 @@ import Foundation extension MainContentCoordinator { - func setupURLNotificationObservers() { + func setupURLNotificationObservers() -> [NSObjectProtocol] { let connId = connectionId - NotificationCenter.default.addObserver( + let observer1 = NotificationCenter.default.addObserver( forName: .applyURLFilter, object: nil, queue: .main @@ -17,7 +17,6 @@ extension MainContentCoordinator { let targetId = userInfo["connectionId"] as? UUID, targetId == connId else { return } - // Extract Sendable values before crossing isolation boundary let condition = userInfo["condition"] as? String let column = userInfo["column"] as? String let operation = userInfo["operation"] as? String @@ -30,7 +29,7 @@ extension MainContentCoordinator { } } - NotificationCenter.default.addObserver( + let observer2 = NotificationCenter.default.addObserver( forName: .switchSchemaFromURL, object: nil, queue: .main @@ -42,7 +41,7 @@ extension MainContentCoordinator { Task { @MainActor [weak self] in guard let self else { return } - + if self.connection.type == .postgresql { await self.switchSchema(to: schema) } else { @@ -50,6 +49,8 @@ extension MainContentCoordinator { } } } + + return [observer1, observer2] } private func applyURLFilterValues( diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b5dc4c9e..8c9e2b89 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -77,6 +77,7 @@ final class MainContentCoordinator { @ObservationIgnored private var changeManagerUpdateTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? + @ObservationIgnored private var urlFilterObservers: [NSObjectProtocol] = [] /// Set during handleTabChange to suppress redundant onChange(of: resultColumns) reconfiguration @ObservationIgnored internal var isHandlingTabSwitch = false @@ -156,7 +157,7 @@ final class MainContentCoordinator { self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) SchemaProviderRegistry.shared.retain(for: connection.id) - setupURLNotificationObservers() + urlFilterObservers = setupURLNotificationObservers() // Synchronous save at quit time. NotificationCenter with queue: .main // delivers the closure on the main thread, satisfying assumeIsolated's @@ -195,6 +196,10 @@ final class MainContentCoordinator { /// synchronously on MainActor so we don't depend on deinit + Task scheduling. func teardown() { _didTeardown.withLock { $0 = true } + for observer in urlFilterObservers { + NotificationCenter.default.removeObserver(observer) + } + urlFilterObservers.removeAll() if let observer = terminationObserver { NotificationCenter.default.removeObserver(observer) terminationObserver = nil