diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d586f1..f698bb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix AI chat hanging the app during streaming, schema fetch, and conversation loading (#735) +- SSH-tunneled connections failing to reconnect after idle/sleep — health monitor now rebuilds the tunnel, OS-level TCP keepalive detects dead NAT mappings, and wake-from-sleep triggers immediate validation (#736) ## [0.31.4] - 2026-04-14 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index f8bc0081..50d07d9e 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -92,6 +92,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected) } } + DatabaseManager.shared.startObservingSystemEvents() + PluginManager.shared.loadPlugins() ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded() diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 94e5badf..a6bdc3e2 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -52,11 +52,12 @@ extension DatabaseManager { guard let self else { return false } guard let session = await self.activeSessions[connectionId] else { return false } do { - let driver = try await self.trackOperation(sessionId: connectionId) { + let result = try await self.trackOperation(sessionId: connectionId) { try await self.reconnectDriver(for: session) } await self.updateSession(connectionId) { session in - session.driver = driver + session.driver = result.driver + session.effectiveConnection = result.effectiveConnection session.status = .connected } return true @@ -103,19 +104,40 @@ extension DatabaseManager { await monitor.startMonitoring() } + /// Result of a driver reconnect, containing the new driver and its effective connection. + internal struct ReconnectResult { + let driver: DatabaseDriver + let effectiveConnection: DatabaseConnection + } + /// Creates a fresh driver, connects, and applies timeout for the given session. - /// Uses the session's effective connection (SSH-tunneled if applicable). - internal func reconnectDriver(for session: ConnectionSession) async throws -> DatabaseDriver { + /// For SSH-tunneled sessions, rebuilds the tunnel before connecting the driver. + internal func reconnectDriver(for session: ConnectionSession) async throws -> ReconnectResult { // Disconnect existing driver session.driver?.disconnect() - // Use effective connection (tunneled) if available, otherwise original - let connectionForDriver = session.effectiveConnection ?? session.connection + // Rebuild SSH tunnel if needed; otherwise reuse effective connection + let connectionForDriver: DatabaseConnection + if session.connection.resolvedSSHConfig.enabled { + connectionForDriver = try await buildEffectiveConnection(for: session.connection) + } else { + connectionForDriver = session.effectiveConnection ?? session.connection + } + let driver = try DatabaseDriverFactory.createDriver( for: connectionForDriver, passwordOverride: session.cachedPassword ) - try await driver.connect() + + do { + try await driver.connect() + } catch { + driver.disconnect() + if session.connection.resolvedSSHConfig.enabled { + try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) + } + throw error + } // Apply timeout let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds @@ -146,7 +168,7 @@ extension DatabaseManager { } } - return driver + return ReconnectResult(driver: driver, effectiveConnection: connectionForDriver) } /// Stop health monitoring for a connection diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index 24b7f655..2514fd00 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -102,9 +102,16 @@ extension DatabaseManager { // MARK: - SSH Tunnel Recovery - /// Handle SSH tunnel death by attempting reconnection with exponential backoff + /// Handle SSH tunnel death by attempting reconnection with exponential backoff. + /// Guarded by `recoveringConnectionIds` to prevent duplicate concurrent recovery + /// when both the keepalive death callback and the wake-from-sleep handler fire + /// for the same connection. func handleSSHTunnelDied(connectionId: UUID) async { - guard let session = activeSessions[connectionId] else { return } + guard let session = activeSessions[connectionId], + !recoveringConnectionIds.contains(connectionId) else { return } + + recoveringConnectionIds.insert(connectionId) + defer { recoveringConnectionIds.remove(connectionId) } Self.logger.warning("SSH tunnel died for connection: \(session.connection.name)") @@ -113,15 +120,15 @@ extension DatabaseManager { // Disconnect the stale driver and invalidate it so connectToSession // creates a fresh connection instead of short-circuiting on driver != nil - session.driver?.disconnect() + activeSessions[connectionId]?.driver?.disconnect() updateSession(connectionId) { session in session.driver = nil session.status = .connecting } - let maxRetries = 5 + let maxRetries = 10 for retryCount in 0..() + /// Current session (computed from currentSessionId) var currentSession: ConnectionSession? { guard let sessionId = currentSessionId else { return nil } diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index c955473a..286229f6 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -125,7 +125,11 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { ) guard rc == 0 else { - Self.logger.error("Keyboard-interactive authentication failed (rc=\(rc))") + var msgPtr: UnsafeMutablePointer? + var msgLen: Int32 = 0 + libssh2_session_last_error(session, &msgPtr, &msgLen, 0) + let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" + Self.logger.error("Keyboard-interactive authentication failed: \(detail)") throw SSHTunnelError.authenticationFailed } diff --git a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift index 1c90d441..62e20a37 100644 --- a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift @@ -4,10 +4,13 @@ // import Foundation +import os import CLibSSH2 internal struct PasswordAuthenticator: SSHAuthenticator { + private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordAuthenticator") + let password: String func authenticate(session: OpaquePointer, username: String) throws { @@ -18,6 +21,11 @@ internal struct PasswordAuthenticator: SSHAuthenticator { nil ) guard rc == 0 else { + var msgPtr: UnsafeMutablePointer? + var msgLen: Int32 = 0 + libssh2_session_last_error(session, &msgPtr, &msgLen, 0) + let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" + Self.logger.error("Password authentication failed: \(detail)") throw SSHTunnelError.authenticationFailed } } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index a823ec78..1c901941 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -385,6 +385,19 @@ internal enum LibSSH2TunnelFactory { // Restore blocking mode for handshake/auth fcntl(fd, F_SETFL, flags) + // Enable OS-level TCP keepalive so the kernel detects dead connections + // (e.g., silent NAT gateway timeout on AWS) independently of libssh2's + // application-level keepalive. macOS uses TCP_KEEPALIVE for the idle + // interval (seconds before the first keepalive probe). + var yes: Int32 = 1 + if setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &yes, socklen_t(MemoryLayout.size)) != 0 { + logger.warning("Failed to set SO_KEEPALIVE: \(String(cString: strerror(errno)))") + } + var keepIdle: Int32 = 60 + if setsockopt(fd, IPPROTO_TCP, TCP_KEEPALIVE, &keepIdle, socklen_t(MemoryLayout.size)) != 0 { + logger.warning("Failed to set TCP_KEEPALIVE: \(String(cString: strerror(errno)))") + } + logger.debug("TCP connected to \(host):\(port)") return fd } @@ -446,15 +459,23 @@ internal enum LibSSH2TunnelFactory { ) throws -> any SSHAuthenticator { switch config.authMethod { case .password where config.totpMode != .none: - // Server requires password + keyboard-interactive for TOTP + // Guard: nil password means the Keychain lookup failed + guard let sshPassword = credentials.sshPassword else { + logger.error("SSH password is nil (Keychain lookup may have failed) for \(config.host)") + throw SSHTunnelError.authenticationFailed + } let totpProvider = buildTOTPProvider(config: config, credentials: credentials) return CompositeAuthenticator(authenticators: [ - PasswordAuthenticator(password: credentials.sshPassword ?? ""), + PasswordAuthenticator(password: sshPassword), KeyboardInteractiveAuthenticator(password: nil, totpProvider: totpProvider), ]) case .password: - return PasswordAuthenticator(password: credentials.sshPassword ?? "") + guard let sshPassword = credentials.sshPassword else { + logger.error("SSH password is nil (Keychain lookup may have failed) for \(config.host)") + throw SSHTunnelError.authenticationFailed + } + return PasswordAuthenticator(password: sshPassword) case .privateKey: let primary = PublicKeyAuthenticator( diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index c7cf9b70..8af40c0a 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -50,6 +50,10 @@ actor SSHTunnelManager { /// Static registry for synchronous termination during app shutdown private static let tunnelRegistry = OSAllocatedUnfairLock(initialState: [UUID: LibSSH2Tunnel]()) + /// Prevents App Nap from throttling SSH keepalive timers while tunnels are active. + /// Held as long as at least one tunnel exists; released when the last tunnel closes. + private var appNapActivity: NSObjectProtocol? + private init() {} /// Create an SSH tunnel for a database connection. @@ -125,6 +129,7 @@ actor SSHTunnelManager { tunnel.startForwarding(remoteHost: remoteHost, remotePort: remotePort) tunnel.startKeepAlive() + updateAppNapState() Self.logger.info("Tunnel created for \(connectionId) on local port \(localPort)") return localPort } catch let error as SSHTunnelError { @@ -144,6 +149,7 @@ actor SSHTunnelManager { func closeTunnel(connectionId: UUID) async throws { guard let tunnel = tunnels.removeValue(forKey: connectionId) else { return } Self.tunnelRegistry.withLock { $0[connectionId] = nil } + updateAppNapState() tunnel.close() } @@ -152,6 +158,7 @@ actor SSHTunnelManager { let currentTunnels = tunnels tunnels.removeAll() Self.tunnelRegistry.withLock { $0.removeAll(); return } + updateAppNapState() for (_, tunnel) in currentTunnels { tunnel.close() @@ -212,7 +219,25 @@ actor SSHTunnelManager { private func handleTunnelDeath(connectionId: UUID) async { guard tunnels.removeValue(forKey: connectionId) != nil else { return } Self.tunnelRegistry.withLock { $0[connectionId] = nil } + updateAppNapState() Self.logger.warning("Tunnel died for connection \(connectionId)") await DatabaseManager.shared.handleSSHTunnelDied(connectionId: connectionId) } + + // MARK: - App Nap Prevention + + /// Acquires or releases an App Nap activity token based on whether tunnels exist. + private func updateAppNapState() { + if !tunnels.isEmpty && appNapActivity == nil { + appNapActivity = ProcessInfo.processInfo.beginActivity( + options: .userInitiatedAllowingIdleSystemSleep, + reason: "SSH tunnel keepalive requires timely execution" + ) + Self.logger.debug("App Nap prevention acquired") + } else if tunnels.isEmpty, let activity = appNapActivity { + ProcessInfo.processInfo.endActivity(activity) + appNapActivity = nil + Self.logger.debug("App Nap prevention released") + } + } }