Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected)
}
}
DatabaseManager.shared.startObservingSystemEvents()

PluginManager.shared.loadPlugins()
ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded()

Expand Down
38 changes: 30 additions & 8 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -146,7 +168,7 @@ extension DatabaseManager {
}
}

return driver
return ReconnectResult(driver: driver, effectiveConnection: connectionForDriver)
}

/// Stop health monitoring for a connection
Expand Down
17 changes: 12 additions & 5 deletions TablePro/Core/Database/DatabaseManager+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand All @@ -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..<maxRetries {
let delay = ExponentialBackoff.delay(for: retryCount + 1, maxDelay: 60)
let delay = ExponentialBackoff.delay(for: retryCount + 1, maxDelay: 120)
Self.logger.info("SSH reconnect attempt \(retryCount + 1)/\(maxRetries) in \(delay)s for: \(session.connection.name)")
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))

Expand Down
51 changes: 51 additions & 0 deletions TablePro/Core/Database/DatabaseManager+SystemEvents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// DatabaseManager+SystemEvents.swift
// TablePro
//
// Handles macOS system events (sleep/wake, network changes) that affect
// database connections, particularly SSH-tunneled sessions.
//

import AppKit
import Foundation
import os

// MARK: - System Event Handling

extension DatabaseManager {
/// Begin observing system events that affect connection health.
/// Call once from `applicationDidFinishLaunching`.
func startObservingSystemEvents() {
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleSystemDidWake),
name: NSWorkspace.didWakeNotification,
object: nil
)
}

@objc private func handleSystemDidWake(_ notification: Notification) {
Self.logger.info("System woke from sleep — validating SSH-tunneled sessions")

Task { @MainActor [weak self] in
guard let self else { return }
await self.validateSSHTunneledSessions()
}
}

/// After waking from sleep, proactively check all SSH-tunneled sessions.
/// If the tunnel is dead, trigger an immediate reconnect rather than waiting
/// for the next 30-second health monitor ping.
private func validateSSHTunneledSessions() async {
for (connectionId, session) in activeSessions {
guard session.connection.resolvedSSHConfig.enabled,
session.isConnected else { continue }

let tunnelAlive = await SSHTunnelManager.shared.hasTunnel(connectionId: connectionId)
if !tunnelAlive {
Self.logger.warning("SSH tunnel missing after wake for: \(session.connection.name)")
await handleSSHTunnelDied(connectionId: connectionId)
}
}
}
}
5 changes: 5 additions & 0 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ final class DatabaseManager {
/// Tracks when the first query started for each session (used for staleness detection).
@ObservationIgnored internal var queryStartTimes: [UUID: Date] = [:]

/// Connection IDs currently undergoing SSH tunnel recovery.
/// Prevents duplicate concurrent recovery when both the keepalive death handler
/// and the wake-from-sleep handler fire for the same connection.
@ObservationIgnored internal var recoveringConnectionIds = Set<UUID>()

/// Current session (computed from currentSessionId)
var currentSession: ConnectionSession? {
guard let sessionId = currentSessionId else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator {
)

guard rc == 0 else {
Self.logger.error("Keyboard-interactive authentication failed (rc=\(rc))")
var msgPtr: UnsafeMutablePointer<CChar>?
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
}

Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/SSH/Auth/PasswordAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +21,11 @@ internal struct PasswordAuthenticator: SSHAuthenticator {
nil
)
guard rc == 0 else {
var msgPtr: UnsafeMutablePointer<CChar>?
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
}
}
Expand Down
27 changes: 24 additions & 3 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int32>.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<Int32>.size)) != 0 {
logger.warning("Failed to set TCP_KEEPALIVE: \(String(cString: strerror(errno)))")
}

logger.debug("TCP connected to \(host):\(port)")
return fd
}
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}

Expand All @@ -152,6 +158,7 @@ actor SSHTunnelManager {
let currentTunnels = tunnels
tunnels.removeAll()
Self.tunnelRegistry.withLock { $0.removeAll(); return }
updateAppNapState()

for (_, tunnel) in currentTunnels {
tunnel.close()
Expand Down Expand Up @@ -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")
}
}
}
Loading