diff --git a/CHANGELOG.md b/CHANGELOG.md index 577a50ae..6a243444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `QueryResultRow` equality ignoring cell values, preventing SwiftUI from re-rendering updated rows - Fix status bar row info text rendering off-center due to duplicate spacer - Fix `Cmd+Delete` in sidebar search or right sidebar clearing the query editor +- Fix SSH tunnel processes not terminated when closing connection window or quitting the app ## [0.14.1] - 2026-03-06 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 36226e20..1ada4589 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -804,6 +804,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + SSHTunnelManager.shared.terminateAllProcessesSync() + // Each MainContentCoordinator observes willTerminateNotification and // synchronously writes tab state via TabDiskActor.saveSync. No additional // action needed here — the per-coordinator observers fire before this returns. diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index d2c660c2..cff4cdee 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -72,6 +72,7 @@ final class DatabaseManager { } @ObservationIgnored nonisolated(unsafe) private var sshTunnelObserver: NSObjectProtocol? + @ObservationIgnored nonisolated(unsafe) private var lastWindowCloseObserver: NSObjectProtocol? private init() { // Observe SSH tunnel failures @@ -87,12 +88,28 @@ final class DatabaseManager { await self.handleSSHTunnelDied(connectionId: connectionId) } } + + lastWindowCloseObserver = NotificationCenter.default.addObserver( + forName: .lastWindowDidClose, + object: nil, + queue: .main + ) { [weak self] notification in + guard let connectionId = notification.userInfo?["connectionId"] as? UUID else { return } + guard let self else { return } + + Task { @MainActor in + await self.disconnectSession(connectionId) + } + } } deinit { if let sshTunnelObserver { NotificationCenter.default.removeObserver(sshTunnelObserver) } + if let lastWindowCloseObserver { + NotificationCenter.default.removeObserver(lastWindowCloseObserver) + } } // MARK: - Session Management diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index df8a2716..14a565dc 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -60,6 +60,7 @@ actor SSHTunnelManager { private let portRangeStart = 60_000 private let portRangeEnd = 65_000 private var healthCheckTask: Task? + private static let processRegistry = OSAllocatedUnfairLock(initialState: [UUID: Process]()) private init() { Task { [weak self] in @@ -201,6 +202,7 @@ actor SSHTunnelManager { createdAt: Date() ) tunnels[connectionId] = tunnel + Self.processRegistry.withLock { $0[connectionId] = launch.process } return localPort } @@ -218,16 +220,43 @@ actor SSHTunnelManager { } tunnels.removeValue(forKey: connectionId) + Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) } } /// Close all SSH tunnels func closeAllTunnels() async { - for (_, tunnel) in tunnels { - if tunnel.process.isRunning { - tunnel.process.terminate() + let currentTunnels = tunnels + tunnels.removeAll() + Self.processRegistry.withLock { $0.removeAll() } + + await withTaskGroup(of: Void.self) { group in + for (_, tunnel) in currentTunnels where tunnel.process.isRunning { + group.addTask { + tunnel.process.terminate() + await self.waitForProcessExit(tunnel.process, timeout: .seconds(3)) + } + } + } + } + + /// Synchronously terminate all SSH tunnel processes. + /// Called from `applicationWillTerminate` where async is not available. + nonisolated func terminateAllProcessesSync() { + let processes = Self.processRegistry.withLock { dict -> [Process] in + let procs = Array(dict.values) + dict.removeAll() + return procs + } + for process in processes where process.isRunning { + process.terminate() + let deadline = Date().addingTimeInterval(1.0) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) } } - tunnels.removeAll() } /// Check if a tunnel exists for a connection @@ -389,12 +418,26 @@ actor SSHTunnelManager { return scriptPath } - /// Wait for a Process to exit without blocking the current thread - private func waitForProcessExit(_ process: Process) async { - await withCheckedContinuation { continuation in - process.terminationHandler = { _ in - continuation.resume() + private func waitForProcessExit(_ process: Process, timeout: Duration = .seconds(5)) async { + guard process.isRunning else { return } + + await withTaskGroup(of: Void.self) { group in + group.addTask { + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } + } + } + group.addTask { + try? await Task.sleep(for: timeout) } + _ = await group.next() + group.cancelAll() + } + + if process.isRunning { + kill(process.processIdentifier, SIGKILL) } } diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index 2d6e365c..1bd5c153 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -33,4 +33,5 @@ extension Notification.Name { // MARK: - SSH static let sshTunnelDied = Notification.Name("sshTunnelDied") + static let lastWindowDidClose = Notification.Name("lastWindowDidClose") } diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 78eb7e44..555fac31 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -122,9 +122,20 @@ internal final class WindowLifecycleMonitor { return } + let closedConnectionId = entry.connectionId + if let observer = entry.observer { NotificationCenter.default.removeObserver(observer) } entries.removeValue(forKey: windowId) + + let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId } + if !hasRemainingWindows { + NotificationCenter.default.post( + name: .lastWindowDidClose, + object: nil, + userInfo: ["connectionId": closedConnectionId] + ) + } } }