From 907ff55a876155b53282987998d17c97741245da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 14 Apr 2026 10:53:01 +0700 Subject: [PATCH 1/2] fix: SSH-tunneled connections failing to reconnect after idle/sleep (#736) The health monitor's reconnectDriver now rebuilds the SSH tunnel instead of reusing a stale localhost port. Added OS-level TCP keepalive on SSH sockets, wake-from-sleep tunnel validation, App Nap prevention, nil password guard with descriptive errors, reentrancy guard for concurrent recovery, and increased retry budget from 5 to 10 attempts. --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 2 + .../Database/DatabaseManager+Health.swift | 38 +++++++++++--- .../Core/Database/DatabaseManager+SSH.swift | 17 +++++-- .../DatabaseManager+SystemEvents.swift | 51 +++++++++++++++++++ TablePro/Core/Database/DatabaseManager.swift | 5 ++ .../KeyboardInteractiveAuthenticator.swift | 10 +++- .../Core/SSH/Auth/PasswordAuthenticator.swift | 8 ++- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 23 +++++++++ TablePro/Core/SSH/SSHTunnelManager.swift | 25 +++++++++ 10 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 TablePro/Core/Database/DatabaseManager+SystemEvents.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d586f12..f698bb843 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 f8bc0081b..50d07d9e0 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 94e5badf9..a6bdc3e2e 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 24b7f655d..2514fd002 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 c955473a3..3e1c21cbf 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -125,8 +125,14 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { ) guard rc == 0 else { - Self.logger.error("Keyboard-interactive authentication failed (rc=\(rc))") - throw SSHTunnelError.authenticationFailed + 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.tunnelCreationFailed( + "Keyboard-interactive authentication failed: \(detail)" + ) } Self.logger.info("Keyboard-interactive authentication succeeded") diff --git a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift index 1c90d4416..862bd2da1 100644 --- a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift @@ -18,7 +18,13 @@ internal struct PasswordAuthenticator: SSHAuthenticator { nil ) guard rc == 0 else { - throw SSHTunnelError.authenticationFailed + var msgPtr: UnsafeMutablePointer? + var msgLen: Int32 = 0 + libssh2_session_last_error(session, &msgPtr, &msgLen, 0) + let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" + throw SSHTunnelError.tunnelCreationFailed( + "Password authentication failed: \(detail)" + ) } } } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index a823ec785..239b39a46 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 } @@ -444,6 +457,16 @@ internal enum LibSSH2TunnelFactory { config: SSHConfiguration, credentials: SSHTunnelCredentials ) throws -> any SSHAuthenticator { + // Guard against nil password for password-based auth methods. + // A nil password means the Keychain lookup failed — proceeding with an + // empty string would always be rejected by the server. + if config.authMethod == .password, credentials.sshPassword == nil { + logger.error("SSH password is nil (Keychain lookup may have failed) for \(config.host)") + throw SSHTunnelError.tunnelCreationFailed( + "SSH password not found. The credential may have been removed from the Keychain." + ) + } + switch config.authMethod { case .password where config.totpMode != .none: // Server requires password + keyboard-interactive for TOTP diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index c7cf9b702..8af40c0ab 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") + } + } } From 2ec4af610003ccf1138031685dea644f8796cf50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 14 Apr 2026 10:57:57 +0700 Subject: [PATCH 2/2] fix: use proper error types for SSH auth failures Log libssh2 detail at error level but throw .authenticationFailed instead of .tunnelCreationFailed to avoid misleading "SSH tunnel creation failed" prefix in user-facing messages. Replace dead ?? "" fallbacks with guard let after nil password check. --- .../KeyboardInteractiveAuthenticator.swift | 4 +--- .../Core/SSH/Auth/PasswordAuthenticator.swift | 8 ++++--- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 24 +++++++++---------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index 3e1c21cbf..286229f6e 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -130,9 +130,7 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { 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.tunnelCreationFailed( - "Keyboard-interactive authentication failed: \(detail)" - ) + throw SSHTunnelError.authenticationFailed } Self.logger.info("Keyboard-interactive authentication succeeded") diff --git a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift index 862bd2da1..62e20a37c 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 { @@ -22,9 +25,8 @@ internal struct PasswordAuthenticator: SSHAuthenticator { var msgLen: Int32 = 0 libssh2_session_last_error(session, &msgPtr, &msgLen, 0) let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" - throw SSHTunnelError.tunnelCreationFailed( - "Password authentication failed: \(detail)" - ) + 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 239b39a46..1c901941e 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -457,27 +457,25 @@ internal enum LibSSH2TunnelFactory { config: SSHConfiguration, credentials: SSHTunnelCredentials ) throws -> any SSHAuthenticator { - // Guard against nil password for password-based auth methods. - // A nil password means the Keychain lookup failed — proceeding with an - // empty string would always be rejected by the server. - if config.authMethod == .password, credentials.sshPassword == nil { - logger.error("SSH password is nil (Keychain lookup may have failed) for \(config.host)") - throw SSHTunnelError.tunnelCreationFailed( - "SSH password not found. The credential may have been removed from the Keychain." - ) - } - 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(