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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- MySQL and MariaDB connections that prompt for a password at connect time now ask for a fresh password after an auto-reconnect hits an authentication failure such as `1045` / `SQLSTATE 28000`, instead of looping forever in Connecting with the expired session password.
- Pressing Escape to dismiss the SQL autocomplete popup no longer moves focus out of the editor, so you can keep typing. (#1845)

## [0.56.1] - 2026-07-09
Expand Down
24 changes: 16 additions & 8 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ extension ConnectionHealthMonitor {
case checking
case reconnecting(attempt: Int) // 1-based attempt number
}

enum ReconnectOutcome: Sendable, Equatable {
case success
case retry
case abort
}
}

// MARK: - ConnectionHealthMonitor
Expand All @@ -40,7 +46,7 @@ actor ConnectionHealthMonitor {

private let connectionId: UUID
private let pingHandler: @Sendable () async -> Bool
private let reconnectHandler: @Sendable () async -> Bool
private let reconnectHandler: @Sendable () async -> ReconnectOutcome
private let onStateChanged: @Sendable (UUID, HealthState) async -> Void

// MARK: - State
Expand All @@ -59,12 +65,12 @@ actor ConnectionHealthMonitor {
/// - pingHandler: Closure that executes a lightweight query (e.g., `SELECT 1`)
/// and returns `true` if the connection is alive.
/// - reconnectHandler: Closure that attempts to re-establish the connection
/// and returns `true` on success.
/// and reports whether the monitor should mark success, retry later, or stop.
/// - onStateChanged: Closure invoked whenever the health state transitions.
init(
connectionId: UUID,
pingHandler: @escaping @Sendable () async -> Bool,
reconnectHandler: @escaping @Sendable () async -> Bool,
reconnectHandler: @escaping @Sendable () async -> ReconnectOutcome,
onStateChanged: @escaping @Sendable (UUID, HealthState) async -> Void
) {
self.connectionId = connectionId
Expand Down Expand Up @@ -188,15 +194,17 @@ actor ConnectionHealthMonitor {
return
}

let success = await reconnectHandler()

if success {
switch await reconnectHandler() {
case .success:
Self.logger.info("Reconnect succeeded on attempt \(attempt) for connection \(self.connectionId)")
await transitionTo(.healthy)
return
case .abort:
Self.logger.info("Reconnect aborted for connection \(self.connectionId)")
return
case .retry:
Self.logger.warning("Reconnect attempt \(attempt) failed for connection \(self.connectionId)")
}

Self.logger.warning("Reconnect attempt \(attempt) failed for connection \(self.connectionId)")
}

Self.logger.debug("Reconnect loop cancelled after \(attempt) attempts for connection \(self.connectionId)")
Expand Down
205 changes: 165 additions & 40 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import TableProPluginKit
// MARK: - Health Monitoring

extension DatabaseManager {
internal enum ReconnectCredentialResolution: Equatable {
case fail
case retry(String)
case abort
}

/// Start health monitoring for a connection
internal func startHealthMonitor(for connectionId: UUID) async {
Self.logger.info("startHealthMonitor called for \(connectionId) (existing monitors: \(self.healthMonitors.count))")
Expand Down Expand Up @@ -49,13 +55,18 @@ extension DatabaseManager {
}
},
reconnectHandler: { [weak self] in
guard let self else { return false }
guard let session = await self.activeSessions[connectionId] else { return false }
guard let self else { return .abort }
guard let session = await self.activeSessions[connectionId] else { return .abort }
await SchemaService.shared.invalidate(connectionId: connectionId)
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId)
do {
let result = try await self.trackOperation(sessionId: connectionId) {
guard let result = try await self.trackOperation(sessionId: connectionId, operation: {
try await self.reconnectDriver(for: session)
}) else {
await self.updateSession(connectionId) { session in
session.status = .disconnected
}
return .abort
}
await self.updateSession(connectionId) { session in
session.driver = result.driver
Expand All @@ -64,11 +75,26 @@ extension DatabaseManager {
if let schemaDriver = result.driver as? SchemaSwitchable {
session.currentSchema = schemaDriver.currentSchema
}
if let cachedPassword = result.cachedPassword,
!session.connection.usesAWSIAM
{
session.cachedPassword = cachedPassword
}
}
return true
return .success
} catch {
Self.logger.debug("Reconnect failed: \(error.localizedDescription)")
return false
// Auth failures are not transient. Retrying with the same expired
// credential just re-prompts on every attempt, so stop the loop.
if await self.isAuthenticationFailure(error) {
await self.updateSession(connectionId) { session in
session.status = .error(
String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription)
)
}
return .abort
}
return .retry
}
},
onStateChanged: { [weak self] id, state in
Expand Down Expand Up @@ -106,11 +132,12 @@ extension DatabaseManager {
internal struct ReconnectResult {
let driver: DatabaseDriver
let effectiveConnection: DatabaseConnection
let cachedPassword: String?
}

/// Creates a fresh driver, connects, and applies timeout for the given session.
/// For SSH-tunneled sessions, rebuilds the tunnel before connecting the driver.
internal func reconnectDriver(for session: ConnectionSession) async throws -> ReconnectResult {
internal func reconnectDriver(for session: ConnectionSession) async throws -> ReconnectResult? {
session.driver?.disconnect()

// Rebuild the tunnel if needed; otherwise reuse effective connection
Expand All @@ -121,32 +148,14 @@ extension DatabaseManager {
connectionForDriver = session.effectiveConnection ?? session.connection
}

let driver = try await DatabaseDriverFactory.createDriver(
for: connectionForDriver,
passwordOverride: session.cachedPassword,
awaitPlugins: true
)

do {
try await driver.connect()
} catch {
driver.disconnect()
if session.connection.resolvedSSHConfig.enabled {
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
} catch {
Self.logger.warning("Failed to close SSH tunnel during reconnect: \(error.localizedDescription)")
}
}
if session.connection.isCloudflareEnabled {
do {
try await CloudflareTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
} catch {
Self.logger.warning("Failed to close Cloudflare tunnel during reconnect: \(error.localizedDescription)")
}
}
throw error
guard let connectResult = try await connectReconnectDriver(
for: session,
effectiveConnection: connectionForDriver,
passwordOverride: session.cachedPassword
) else {
return nil
}
let driver = connectResult.driver

await applyTimeoutAndStartupCommands(
on: driver,
Expand All @@ -159,7 +168,11 @@ extension DatabaseManager {
savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : session.currentDatabase
)

return ReconnectResult(driver: driver, effectiveConnection: connectionForDriver)
return ReconnectResult(
driver: driver,
effectiveConnection: connectionForDriver,
cachedPassword: connectResult.cachedPassword
)
}

func applyTimeoutAndStartupCommands(
Expand Down Expand Up @@ -260,12 +273,15 @@ extension DatabaseManager {
passwordOverride = prompted
}

let driver = try await DatabaseDriverFactory.createDriver(
for: effectiveConnection,
passwordOverride: passwordOverride,
awaitPlugins: true
)
try await driver.connect()
guard let connectResult = try await connectReconnectDriver(
for: session,
effectiveConnection: effectiveConnection,
passwordOverride: passwordOverride
) else {
updateSession(sessionId) { $0.status = .disconnected }
return
}
let driver = connectResult.driver

await applyTimeoutAndStartupCommands(
on: driver,
Expand All @@ -285,8 +301,10 @@ extension DatabaseManager {
if let schemaDriver = driver as? SchemaSwitchable {
session.currentSchema = schemaDriver.currentSchema
}
if let passwordOverride, !session.connection.usesAWSIAM {
session.cachedPassword = passwordOverride
if let cachedPassword = connectResult.cachedPassword,
!session.connection.usesAWSIAM
{
session.cachedPassword = cachedPassword
}
}

Expand All @@ -311,4 +329,111 @@ extension DatabaseManager {
}
}
}

internal func connectReconnectDriver(
for session: ConnectionSession,
effectiveConnection: DatabaseConnection,
passwordOverride initialPasswordOverride: String?
) async throws -> (driver: DatabaseDriver, cachedPassword: String?)? {
var passwordOverride = initialPasswordOverride

while true {
let driver = try await DatabaseDriverFactory.createDriver(
for: effectiveConnection,
passwordOverride: passwordOverride,
awaitPlugins: true
)

do {
try await driver.connect()
return (driver, passwordOverride)
} catch {
driver.disconnect()

switch await reconnectCredentialResolution(
for: session,
error: error,
currentPassword: passwordOverride
) {
case .retry(let newPassword):
passwordOverride = newPassword
case .abort:
await closeReconnectTunnels(for: session.connection)
return nil
case .fail:
await closeReconnectTunnels(for: session.connection)
throw error
}
}
}
}

internal func reconnectCredentialResolution(
for session: ConnectionSession,
error: Error,
currentPassword: String?,
prompt: @escaping @MainActor (_ connectionName: String, _ isAPIToken: Bool, _ window: NSWindow?) async -> String? = PasswordPromptHelper.prompt
) async -> ReconnectCredentialResolution {
guard session.connection.promptForPassword,
!pluginManager.hidesPassword(for: session.connection),
isAuthenticationFailure(error)
else {
return .fail
}

let isApiOnly = pluginManager.connectionMode(for: session.connection.type) == .apiOnly
guard let prompted = await prompt(
session.connection.name,
isApiOnly,
NSApp.keyWindow
) else {
return .abort
}

if prompted == currentPassword {
return .fail
}

return .retry(prompted)
}

private static let invalidAuthorizationSQLState = "28000"
private static let mysqlAccessDeniedErrorCode = 1_045

internal func isAuthenticationFailure(_ error: Error) -> Bool {
if let pluginError = error as? any PluginDriverError {
if pluginError.pluginSqlState == Self.invalidAuthorizationSQLState {
return true
}
if pluginError.pluginErrorCode == Self.mysqlAccessDeniedErrorCode {
return true
}
return messageIndicatesAuthenticationFailure(pluginError.pluginErrorMessage)
}
return messageIndicatesAuthenticationFailure(error.localizedDescription)
}

private func messageIndicatesAuthenticationFailure(_ message: String) -> Bool {
let lowered = message.lowercased()
return lowered.contains("access denied")
|| lowered.contains("authentication failed")
|| lowered.contains("invalid credentials")
}

private func closeReconnectTunnels(for connection: DatabaseConnection) async {
if connection.resolvedSSHConfig.enabled {
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("Failed to close SSH tunnel during reconnect: \(error.localizedDescription)")
}
}
if connection.isCloudflareEnabled {
do {
try await CloudflareTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("Failed to close Cloudflare tunnel during reconnect: \(error.localizedDescription)")
}
}
}
}
1 change: 0 additions & 1 deletion TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
// Created by Ngo Quoc Dat on 16/12/25.
//

import AppKit
import Foundation
import Observation
import os
Expand Down
Loading