diff --git a/CHANGELOG.md b/CHANGELOG.md index 193784a44..a4fdab080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The ER diagram now arranges tables in a compact layout that fills the canvas in both directions, keeps tables linked by foreign keys together, and tints each group of connected tables with its own header color. (#1755) +- When an Oracle server closes the connection during login, the error dialog now shows which handshake phase it stopped at, so dropped-handshake failures (for example on Oracle 11g) are easier to pin down. (#1746) ### Fixed diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index e94a21b30..bbf182816 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -27,7 +27,7 @@ struct OracleError: Error { case protocolError case authVerifierUnsupported(flag: String) case authVersionNotSupported - case authConnectionDropped + case authConnectionDropped(phase: String?) } let message: String @@ -191,7 +191,8 @@ final class OracleConnectionWrapper: @unchecked Sendable { osLogger.debug("Connected to Oracle \(target)") } catch let sqlError as OracleSQLError { let detail = Self.connectFailureDetail(sqlError) - osLogger.error("Oracle connection failed: \(detail)") + let phase = sqlError.handshakePhase ?? "unknown" + osLogger.error("Oracle connection failed at phase \(phase, privacy: .public) (\(sqlError.code.description, privacy: .public)): \(detail)") if let sslError = OracleSSLClassifier.classifySSLError(detail) { throw sslError } @@ -215,16 +216,14 @@ final class OracleConnectionWrapper: @unchecked Sendable { } private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category { - let codeDescription = error.code.description - if codeDescription.hasPrefix("unsupportedVerifierType") { - return .authVerifierUnsupported(flag: codeDescription) - } - switch codeDescription { - case "uncleanShutdown": - return .authConnectionDropped - case "serverVersionNotSupported": + switch OracleConnectErrorClassifier.classify(error.code.description) { + case .verifierUnsupported(let flag): + return .authVerifierUnsupported(flag: flag) + case .versionNotSupported: return .authVersionNotSupported - default: + case .connectionDropped: + return .authConnectionDropped(phase: error.handshakePhase) + case .connectionFailed: return .connectionFailed } } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index c761298ab..b2123db70 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -140,15 +140,17 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost ], supportURL: issuesURL ) - case .authConnectionDropped: + case .authConnectionDropped(let phase): return PluginDiagnostic( title: String(localized: "Connection Dropped During Handshake"), message: oracleError.message, suggestedActions: [ String(localized: "Check for a firewall, VPN, or load balancer between you and the server that closes connections mid-handshake."), String(localized: "If the listener endpoint is TLS-only (TCPS), set the SSL mode in the connection's SSL settings."), - String(localized: "Confirm the host and port reach the database listener directly, not a proxy that resets unknown traffic.") + String(localized: "Confirm the host and port reach the database listener directly, not a proxy that resets unknown traffic."), + String(localized: "If this is Oracle 11g, open an issue and include the handshake phase shown below.") ], + diagnosticInfo: phase.map { [DiagnosticEntry(label: String(localized: "Handshake phase"), value: $0)] } ?? [], supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") ) case .authVersionNotSupported: diff --git a/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift b/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift new file mode 100644 index 000000000..56bdca0c0 --- /dev/null +++ b/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum OracleConnectFailure: Sendable, Equatable { + case verifierUnsupported(flag: String) + case versionNotSupported + case connectionDropped + case connectionFailed +} + +public enum OracleConnectErrorClassifier { + public static func classify(_ codeDescription: String) -> OracleConnectFailure { + if codeDescription.hasPrefix("unsupportedVerifierType") { + return .verifierUnsupported(flag: codeDescription) + } + switch codeDescription { + case "uncleanShutdown": + return .connectionDropped + case "serverVersionNotSupported": + return .versionNotSupported + default: + return .connectionFailed + } + } +} diff --git a/TableProTests/Plugins/OracleConnectionErrorTests.swift b/TableProTests/Plugins/OracleConnectionErrorTests.swift index fd17f9a06..20c844715 100644 --- a/TableProTests/Plugins/OracleConnectionErrorTests.swift +++ b/TableProTests/Plugins/OracleConnectionErrorTests.swift @@ -1,5 +1,5 @@ -import Testing import TableProPluginKit +import Testing @Suite("Oracle channel-fatal error classification") struct OracleConnectionErrorTests { @@ -17,3 +17,30 @@ struct OracleConnectionErrorTests { #expect(!OracleChannelFatalCode.isChannelFatal("malformedStatement")) } } + +@Suite("Oracle connect error classification") +struct OracleConnectErrorClassifierTests { + @Test("An unclean shutdown is a dropped handshake") + func uncleanShutdownIsDropped() { + #expect(OracleConnectErrorClassifier.classify("uncleanShutdown") == .connectionDropped) + } + + @Test("An unsupported server version is reported as such") + func serverVersionNotSupported() { + #expect(OracleConnectErrorClassifier.classify("serverVersionNotSupported") == .versionNotSupported) + } + + @Test("An unsupported verifier carries its flag through") + func verifierCarriesFlag() { + #expect( + OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x939)") + == .verifierUnsupported(flag: "unsupportedVerifierType(0x939)") + ) + } + + @Test("Any other code falls back to a generic connection failure") + func unknownIsConnectionFailed() { + #expect(OracleConnectErrorClassifier.classify("connectionError") == .connectionFailed) + #expect(OracleConnectErrorClassifier.classify("server") == .connectionFailed) + } +} diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 36eeab2b7..76b29e5db 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -148,4 +148,6 @@ Columns with types not yet supported render as `` rather than **Instant Client not found**: Download Basic package, extract to `/usr/local/oracle/instantclient`, set `DYLD_LIBRARY_PATH` +**Connection dropped during handshake**: The server closed the connection mid-login. The error dialog shows the handshake phase it stopped at (for example `dataTypeNegotiation` or `authentication`). Check for a firewall, VPN, or proxy that resets traffic, and confirm the host and port reach the listener directly. On Oracle 11g, open an issue and include the handshake phase. + **Limitations**: Username/password only, BFILE shows locator metadata only (content fetch via DBMS_LOB not supported), PL/SQL limited to anonymous blocks.