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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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",
Comment on lines +833 to +840

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve bad-cipher handshakes as cipher mismatches

With this narrowed signature list, a genuine libmongoc message such as TLS handshake failed: bad cipher (the case the previous test covered) no longer matches and then falls through to the generic handshake branch as .unknown. Users in that cipher-negotiation failure case lose the cipher/protocol recovery guidance, so include the bad-cipher wording in the mismatch signatures rather than treating it as unknown.

Useful? React with 👍 / 👎.

]
return signatures.contains { lower.contains($0) }
}
}

// bsonToDict and bsonToJson take bson_t parameters (a CLibMongoc type),
Expand Down
37 changes: 32 additions & 5 deletions Plugins/OracleDriverPlugin/OracleConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct OracleError: Error {
case notConnected
case connectionFailed
case queryFailed
case protocolError
case authVerifierUnsupported(flag: String)
case authVersionNotSupported
case authConnectionDropped
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Comment on lines 384 to +385

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent queued Oracle calls from using the reset connection

When a channel-fatal OracleSQLError occurs while another executeQuery/streamQuery call is waiting on queryGate, this releases the gate before (and independently of) invalidating the wrapper, and waiting calls have already copied connection before acquiring the gate. The resumed waiter can therefore execute on the same poisoned OracleNIO connection instead of seeing notConnected/reconnect, so the new fatal-error reset doesn't actually stop queued work from querying a dead channel.

Useful? React with 👍 / 👎.

} catch let error as OracleError {
await queryGate.release()
throw error
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -4779,7 +4779,7 @@
repositoryURL = "https://github.com/TableProApp/oracle-nio";
requirement = {
kind = revision;
revision = 8b39c44c22ff23659ec085def01c767f5e141196;
revision = 54b73e30eeb9157c483be89c53a0f0be702b1617;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions TableProTests/PluginTestSources/PluginSSLClassifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions TableProTests/Plugins/OracleConnectionErrorTests.swift
Original file line number Diff line number Diff line change
@@ -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"))

Check failure on line 10 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
#expect(OracleConnectionWrapper.isChannelFatalCode("messageDecodingFailure"))

Check failure on line 11 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
#expect(OracleConnectionWrapper.isChannelFatalCode("unexpectedBackendMessage"))

Check failure on line 12 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
}

@Test("Server-side SQL errors keep the connection alive")
func nonFatalCodes() {
#expect(!OracleConnectionWrapper.isChannelFatalCode("server"))

Check failure on line 17 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
#expect(!OracleConnectionWrapper.isChannelFatalCode("statementCancelled"))

Check failure on line 18 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
#expect(!OracleConnectionWrapper.isChannelFatalCode("malformedStatement"))

Check failure on line 19 in TableProTests/Plugins/OracleConnectionErrorTests.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

cannot find 'OracleConnectionWrapper' in scope
}
}
24 changes: 21 additions & 3 deletions TableProTests/Plugins/PluginSSLClassifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading