diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba1a9be1..0697c32b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Oracle connections no longer crash the app when the server sends a backend message the driver cannot decode; the query fails with a clear error and the connection reconnects. (#483) +- MongoDB TLS handshake failures now report the actual cause instead of always blaming a cipher or protocol mismatch. (#1418) + ## [0.52.0] - 2026-06-19 ### Added diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 8e05ab707..f2cf334ed 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -808,9 +808,6 @@ extension MongoDBConnection { static func classifySSLError(_ message: String) -> SSLHandshakeError? { let lower = message.lowercased() - if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") { - return .cipherMismatch(serverMessage: message) - } if lower.contains("certificate verify failed") || lower.contains("ssl certificate") { return .untrustedCertificate(serverMessage: message) } @@ -823,8 +820,27 @@ extension MongoDBConnection { if lower.contains("client certificate required") || lower.contains("peer did not return a certificate") { return .clientCertRequired(serverMessage: message) } + if isCipherOrProtocolMismatch(lower) { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") { + return .unknown(serverMessage: message) + } return nil } + + static func isCipherOrProtocolMismatch(_ lower: String) -> Bool { + let signatures = [ + "no shared cipher", + "sslv3 alert handshake failure", + "wrong version number", + "unsupported protocol", + "no protocols available", + "alert protocol version", + "protocol version", + ] + return signatures.contains { lower.contains($0) } + } } // bsonToDict and bsonToJson take bson_t parameters (a CLibMongoc type), diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index d843dd342..79b9228ad 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -24,6 +24,7 @@ struct OracleError: Error { case notConnected case connectionFailed case queryFailed + case protocolError case authVerifierUnsupported(flag: String) case authVersionNotSupported case authConnectionDropped @@ -264,11 +265,39 @@ final class OracleConnectionWrapper: @unchecked Sendable { return String(localized: "The Oracle server closed the connection during the login handshake.") case .authVerifierUnsupported: return String(localized: "This account uses a password verifier the database driver does not support.") - case .generic, .notConnected, .connectionFailed, .queryFailed: + case .generic, .notConnected, .connectionFailed, .queryFailed, .protocolError: return serverDetail } } + private func mapQueryError(_ sqlError: OracleSQLError) -> OracleError { + guard Self.isChannelFatal(sqlError) else { + return OracleError(message: sqlError.serverInfo?.message ?? sqlError.description) + } + state.withLock { current in + current.isConnected = false + current.nioConnection = nil + } + osLogger.error("Oracle connection reset after fatal protocol error: \(sqlError.code.description)") + return OracleError( + message: String(localized: "The server sent an unexpected message and the connection was reset. Run the query again."), + category: .protocolError + ) + } + + private static func isChannelFatal(_ error: OracleSQLError) -> Bool { + isChannelFatalCode(error.code.description) + } + + static func isChannelFatalCode(_ codeDescription: String) -> Bool { + switch codeDescription { + case "connectionError", "messageDecodingFailure", "unexpectedBackendMessage": + return true + default: + return false + } + } + func disconnect() { let connection = state.withLock { current -> OracleNIO.OracleConnection? in guard current.isConnected else { return nil } @@ -352,9 +381,8 @@ final class OracleConnectionWrapper: @unchecked Sendable { isTruncated: truncated ) } catch let sqlError as OracleSQLError { - let detail = sqlError.serverInfo?.message ?? sqlError.description await queryGate.release() - throw OracleError(message: detail) + throw mapQueryError(sqlError) } catch let error as OracleError { await queryGate.release() throw error @@ -438,9 +466,8 @@ final class OracleConnectionWrapper: @unchecked Sendable { await queryGate.release() continuation.finish() } catch let sqlError as OracleSQLError { - let detail = sqlError.serverInfo?.message ?? sqlError.description await queryGate.release() - throw OracleError(message: detail) + throw mapQueryError(sqlError) } catch is CancellationError { await queryGate.release() throw CancellationError() diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 16a72c072..c761298ab 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -161,6 +161,16 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost ], supportURL: issuesURL ) + case .protocolError: + return PluginDiagnostic( + title: String(localized: "Connection Reset"), + message: oracleError.message, + suggestedActions: [ + String(localized: "Run the query again. TablePro reconnects to the server automatically."), + String(localized: "If the same query keeps failing, the server may be returning data the driver cannot decode. File an issue with your Oracle version.") + ], + supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") + ) case .generic, .notConnected, .connectionFailed, .queryFailed: return nil } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 0236b72f0..729774a88 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -4779,7 +4779,7 @@ repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { kind = revision; - revision = 8b39c44c22ff23659ec085def01c767f5e141196; + revision = 54b73e30eeb9157c483be89c53a0f0be702b1617; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ea8506ab..763f052dc 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "8b39c44c22ff23659ec085def01c767f5e141196" + "revision" : "54b73e30eeb9157c483be89c53a0f0be702b1617" } }, { diff --git a/TableProTests/PluginTestSources/PluginSSLClassifiers.swift b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift index 63c00436d..154eb0f08 100644 --- a/TableProTests/PluginTestSources/PluginSSLClassifiers.swift +++ b/TableProTests/PluginTestSources/PluginSSLClassifiers.swift @@ -72,9 +72,6 @@ enum FreeTDSClassifier { enum MongoDBClassifier { static func classifySSLError(_ message: String) -> SSLHandshakeError? { let lower = message.lowercased() - if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") { - return .cipherMismatch(serverMessage: message) - } if lower.contains("certificate verify failed") || lower.contains("ssl certificate") { return .untrustedCertificate(serverMessage: message) } @@ -87,8 +84,27 @@ enum MongoDBClassifier { if lower.contains("client certificate required") || lower.contains("peer did not return a certificate") { return .clientCertRequired(serverMessage: message) } + if isCipherOrProtocolMismatch(lower) { + return .cipherMismatch(serverMessage: message) + } + if lower.contains("ssl handshake failed") || lower.contains("tls handshake failed") { + return .unknown(serverMessage: message) + } return nil } + + static func isCipherOrProtocolMismatch(_ lower: String) -> Bool { + let signatures = [ + "no shared cipher", + "sslv3 alert handshake failure", + "wrong version number", + "unsupported protocol", + "no protocols available", + "alert protocol version", + "protocol version", + ] + return signatures.contains { lower.contains($0) } + } } enum RedisClassifier { diff --git a/TableProTests/Plugins/OracleConnectionErrorTests.swift b/TableProTests/Plugins/OracleConnectionErrorTests.swift new file mode 100644 index 000000000..3ce3c6111 --- /dev/null +++ b/TableProTests/Plugins/OracleConnectionErrorTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("Oracle channel-fatal error classification") +struct OracleConnectionErrorTests { + @Test("Decode and connection failures are treated as channel-fatal") + func channelFatalCodes() { + #expect(OracleConnectionWrapper.isChannelFatalCode("connectionError")) + #expect(OracleConnectionWrapper.isChannelFatalCode("messageDecodingFailure")) + #expect(OracleConnectionWrapper.isChannelFatalCode("unexpectedBackendMessage")) + } + + @Test("Server-side SQL errors keep the connection alive") + func nonFatalCodes() { + #expect(!OracleConnectionWrapper.isChannelFatalCode("server")) + #expect(!OracleConnectionWrapper.isChannelFatalCode("statementCancelled")) + #expect(!OracleConnectionWrapper.isChannelFatalCode("malformedStatement")) + } +} diff --git a/TableProTests/Plugins/PluginSSLClassifierTests.swift b/TableProTests/Plugins/PluginSSLClassifierTests.swift index ae6f4d1a6..41a33812c 100644 --- a/TableProTests/Plugins/PluginSSLClassifierTests.swift +++ b/TableProTests/Plugins/PluginSSLClassifierTests.swift @@ -115,14 +115,32 @@ struct FreeTDSClassifierTests { @Suite("MongoDB SSL Classifier") struct MongoDBClassifierTests { - @Test("TLS handshake failed → cipherMismatch") - func testTLSHandshake() { - guard case .cipherMismatch = MongoDBClassifier.classifySSLError("TLS handshake failed: bad cipher") else { + @Test("Atlas internal-error handshake failure → unknown, not cipherMismatch") + func testAtlasInternalErrorHandshake() { + let message = "No suitable servers found: [TLS handshake failed: internal error (-9838) " + + "calling hello on 'ac-zmho1ul-shard-00-00.dsllzcf.mongodb.net:27017']" + guard case .unknown = MongoDBClassifier.classifySSLError(message) else { + Issue.record("Expected unknown for a generic handshake failure") + return + } + } + + @Test("Genuine cipher/protocol failure → cipherMismatch") + func testGenuineCipherMismatch() { + guard case .cipherMismatch = MongoDBClassifier.classifySSLError("TLS handshake failed: sslv3 alert handshake failure: no shared cipher") else { Issue.record("Expected cipherMismatch") return } } + @Test("Certificate verify failure → untrustedCertificate") + func testCertificateVerifyFailed() { + guard case .untrustedCertificate = MongoDBClassifier.classifySSLError("TLS handshake failed: certificate verify failed") else { + Issue.record("Expected untrustedCertificate") + return + } + } + @Test("Hostname verification failure → hostnameMismatch") func testHostnameVerification() { guard case .hostnameMismatch = MongoDBClassifier.classifySSLError("hostname verification failed") else {