From b372d58748fb0de05769c883524230f1dcfe0b94 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 30 Jun 2026 05:50:51 +0700 Subject: [PATCH] fix(plugin-oracle): bound the login handshake and explain native encryption stalls Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- CHANGELOG.md | 1 + Packages/TableProCore/Package.swift | 5 ++ .../AsyncTimeoutTests.swift | 35 +++++++++ .../OracleConnectErrorClassifierTests.swift | 47 ++++++++++++ .../OracleDriverPlugin/OracleConnection.swift | 72 +++++++++++++++---- Plugins/OracleDriverPlugin/OraclePlugin.swift | 22 ++++++ Plugins/TableProPluginKit/AsyncTimeout.swift | 27 +++++++ .../OracleConnectErrorClassifier.swift | 15 ++++ docs/databases/oracle.mdx | 4 +- 9 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProPluginKitTests/AsyncTimeoutTests.swift create mode 100644 Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift create mode 100644 Plugins/TableProPluginKit/AsyncTimeout.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfb30458..6fa8fd38f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) - Restored table tabs now load with the current page size instead of the page size from the previous session. - MSSQL: large `nvarchar(max)` and `text` values no longer truncate to 2048 bytes when copied or viewed. TEXTSIZE is raised at connect time. (#1783) +- Oracle connections with Native network encryption turned on no longer hang for about a minute against servers that do not complete it, such as Oracle 11g. The login now stops after 30 seconds and explains how to turn the option off. (#1746) ## [0.53.0] - 2026-06-25 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index a186696fc..07d76a7ad 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -100,6 +100,11 @@ let package = Package( name: "TableProSyncTests", dependencies: ["TableProSync", "TableProModels"], path: "Tests/TableProSyncTests" + ), + .testTarget( + name: "TableProPluginKitTests", + dependencies: ["TableProPluginKit"], + path: "Tests/TableProPluginKitTests" ) ] ) diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/AsyncTimeoutTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/AsyncTimeoutTests.swift new file mode 100644 index 000000000..7ca5759c1 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/AsyncTimeoutTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import TableProPluginKit + +final class AsyncTimeoutTests: XCTestCase { + func testReturnsValueWhenOperationFinishesBeforeTimeout() async throws { + let value = try await withTimeout(seconds: 5) { 42 } + XCTAssertEqual(value, 42) + } + + func testThrowsTimeoutWhenOperationStalls() async { + do { + _ = try await withTimeout(seconds: 0.05) { () -> Int in + try await Task.sleep(nanoseconds: 5_000_000_000) + return 1 + } + XCTFail("Expected TimeoutError") + } catch let error as TimeoutError { + XCTAssertEqual(error.seconds, 0.05) + } catch { + XCTFail("Expected TimeoutError, got \(error)") + } + } + + func testPropagatesOperationError() async { + struct Boom: Error {} + do { + _ = try await withTimeout(seconds: 5) { () -> Int in throw Boom() } + XCTFail("Expected Boom") + } catch is Boom { + // expected + } catch { + XCTFail("Expected Boom, got \(error)") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift b/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift new file mode 100644 index 000000000..a4b4f2082 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProPluginKitTests/OracleConnectErrorClassifierTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import TableProPluginKit + +final class OracleConnectErrorClassifierTests: XCTestCase { + func testClassifyKnownCodes() { + XCTAssertEqual(OracleConnectErrorClassifier.classify("uncleanShutdown"), .connectionDropped) + XCTAssertEqual(OracleConnectErrorClassifier.classify("serverVersionNotSupported"), .versionNotSupported) + XCTAssertEqual(OracleConnectErrorClassifier.classify("somethingElse"), .connectionFailed) + XCTAssertEqual( + OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x12)"), + .verifierUnsupported(flag: "unsupportedVerifierType(0x12)") + ) + } + + func testEncryptionFailureRequiresEncryptionEnabled() { + XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .connectionFailed, nativeNetworkEncryptionEnabled: false, timedOut: true + )) + XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .connectionDropped, nativeNetworkEncryptionEnabled: false, timedOut: false + )) + } + + func testTimeoutWithEncryptionIsEncryptionFailure() { + XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .connectionFailed, nativeNetworkEncryptionEnabled: true, timedOut: true + )) + } + + func testHandshakeDropWithEncryptionIsEncryptionFailure() { + XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .connectionDropped, nativeNetworkEncryptionEnabled: true, timedOut: false + )) + XCTAssertTrue(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .connectionFailed, nativeNetworkEncryptionEnabled: true, timedOut: false + )) + } + + func testAuthErrorsAreNotEncryptionFailures() { + XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .verifierUnsupported(flag: "x"), nativeNetworkEncryptionEnabled: true, timedOut: false + )) + XCTAssertFalse(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: .versionNotSupported, nativeNetworkEncryptionEnabled: true, timedOut: false + )) + } +} diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 9130f8adb..998b7e6a1 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -28,6 +28,8 @@ struct OracleError: Error { case authVerifierUnsupported(flag: String) case authVersionNotSupported case authConnectionDropped(phase: String?) + case loginTimedOut + case nativeEncryptionFailed } let message: String @@ -173,6 +175,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { tls: tls ) config.nativeNetworkEncryption = nativeNetworkEncryption + let connectConfig = config let connectionId = Self.connectionCounter.withLock { state -> Int in state += 1 @@ -180,11 +183,13 @@ final class OracleConnectionWrapper: @unchecked Sendable { } do { - let connection = try await OracleNIO.OracleConnection.connect( - configuration: config, - id: connectionId, - logger: nioLogger - ) + let connection = try await withTimeout(seconds: Self.loginTimeoutSeconds) { + try await OracleNIO.OracleConnection.connect( + configuration: connectConfig, + id: connectionId, + logger: nioLogger + ) + } state.withLock { current in current.nioConnection = connection @@ -193,6 +198,9 @@ final class OracleConnectionWrapper: @unchecked Sendable { let target = useSID ? "\(self.host):\(self.port):\(identifier)" : "\(self.host):\(self.port)/\(identifier)" osLogger.debug("Connected to Oracle \(target)") + } catch is TimeoutError { + osLogger.error("Oracle login handshake timed out after \(Self.loginTimeoutSeconds, privacy: .public)s (nativeEncryption=\(self.nativeNetworkEncryption, privacy: .public))") + throw makeConnectError(failure: .connectionFailed, detail: "", timedOut: true, phase: nil) } catch let sqlError as OracleSQLError { let detail = Self.connectFailureDetail(sqlError) let phase = sqlError.handshakePhase ?? "unknown" @@ -200,11 +208,8 @@ final class OracleConnectionWrapper: @unchecked Sendable { if let sslError = OracleSSLClassifier.classifySSLError(detail) { throw sslError } - let category = classifyConnectError(sqlError) - throw OracleError( - message: Self.connectErrorMessage(for: category, serverDetail: detail), - category: category - ) + let failure = OracleConnectErrorClassifier.classify(sqlError.code.description) + throw makeConnectError(failure: failure, detail: detail, timedOut: false, phase: sqlError.handshakePhase) } catch let nioSslError as NIOSSLError { let detail = String(describing: nioSslError) osLogger.error("Oracle TLS error: \(detail)") @@ -219,19 +224,56 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } - private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category { - switch OracleConnectErrorClassifier.classify(error.code.description) { + private static let loginTimeoutSeconds: Double = 30 + + private func makeConnectError( + failure: OracleConnectFailure, + detail: String, + timedOut: Bool, + phase: String? + ) -> OracleError { + if OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( + failure: failure, + nativeNetworkEncryptionEnabled: nativeNetworkEncryption, + timedOut: timedOut + ) { + return OracleError(message: Self.nativeEncryptionFailureMessage, category: .nativeEncryptionFailed) + } + if timedOut { + return OracleError(message: Self.loginTimeoutMessage, category: .loginTimedOut) + } + let category = Self.category(for: failure, phase: phase) + return OracleError( + message: Self.connectErrorMessage(for: category, serverDetail: detail), + category: category + ) + } + + private static func category(for failure: OracleConnectFailure, phase: String?) -> OracleError.Category { + switch failure { case .verifierUnsupported(let flag): return .authVerifierUnsupported(flag: flag) case .versionNotSupported: return .authVersionNotSupported case .connectionDropped: - return .authConnectionDropped(phase: error.handshakePhase) + return .authConnectionDropped(phase: phase) case .connectionFailed: return .connectionFailed } } + private static let loginTimeoutMessage = String( + localized: "Timed out during the Oracle login handshake. The server accepted the network connection but did not finish logging in." + ) + + private static let nativeEncryptionFailureMessage = String( + localized: """ + Could not finish logging in with native network encryption enabled. \ + This Oracle server may not support it, which is common with Oracle 11g. \ + Turn off the Native network encryption option in this connection's settings and try again. + """ + ) + private static func connectFailureDetail(_ error: OracleSQLError) -> String { if let refused = error.underlying as? OracleListenerRefusedError { return OracleListenerRefusal.detail(code: refused.code) @@ -250,6 +292,10 @@ 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 .loginTimedOut: + return loginTimeoutMessage + case .nativeEncryptionFailed: + return nativeEncryptionFailureMessage case .generic, .notConnected, .connectionFailed, .queryFailed, .protocolError: return serverDetail } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 2d514ad1c..b4e8465da 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -179,6 +179,28 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost ], supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") ) + case .nativeEncryptionFailed: + return PluginDiagnostic( + title: String(localized: "Native Network Encryption Not Completed"), + message: oracleError.message, + suggestedActions: [ + String(localized: "Turn off the Native network encryption option in this connection's settings, then connect again."), + String(localized: "Some servers, including Oracle 11g, accept but never complete native network encryption. Plain connections to such servers still work."), + String(localized: "If you need encryption in transit, use TLS instead by setting an SSL mode in the connection's SSL settings.") + ], + supportURL: issuesURL + ) + case .loginTimedOut: + return PluginDiagnostic( + title: String(localized: "Login Handshake Timed Out"), + message: oracleError.message, + suggestedActions: [ + String(localized: "Check for a firewall, VPN, or proxy between you and the server that stalls connections after the TCP handshake."), + String(localized: "Confirm the host and port reach the database listener directly."), + String(localized: "If you enabled the Native network encryption option, turn it off; some servers stall instead of completing it.") + ], + supportURL: issuesURL + ) case .generic, .notConnected, .connectionFailed, .queryFailed: return nil } diff --git a/Plugins/TableProPluginKit/AsyncTimeout.swift b/Plugins/TableProPluginKit/AsyncTimeout.swift new file mode 100644 index 000000000..7e4381f7f --- /dev/null +++ b/Plugins/TableProPluginKit/AsyncTimeout.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct TimeoutError: Error, Sendable, Equatable { + public let seconds: Double + + public init(seconds: Double) { + self.seconds = seconds + } +} + +public func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError(seconds: seconds) + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw TimeoutError(seconds: seconds) + } + return result + } +} diff --git a/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift b/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift index 56bdca0c0..c3c35a5d0 100644 --- a/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift +++ b/Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift @@ -21,4 +21,19 @@ public enum OracleConnectErrorClassifier { return .connectionFailed } } + + public static func isLikelyNativeEncryptionFailure( + failure: OracleConnectFailure, + nativeNetworkEncryptionEnabled: Bool, + timedOut: Bool + ) -> Bool { + guard nativeNetworkEncryptionEnabled else { return false } + if timedOut { return true } + switch failure { + case .connectionDropped, .connectionFailed: + return true + case .verifierUnsupported, .versionNotSupported: + return false + } + } } diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 6a058dadd..c002363a5 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -40,7 +40,7 @@ The Oracle driver is available as a downloadable plugin. When you select Oracle | **Port** | `1521` | Listener port | | **Service Name** | - | **Required**. Check `tnsnames.ora` if unclear. To connect by SID instead, set **Connection Type** to SID and enter the SID. | | **Username** | - | Requires username/password auth (no OS auth) | -| **Native network encryption** | Off | Advanced. Turn on only if the server requires encrypted traffic (`SQLNET.ENCRYPTION_SERVER = REQUIRED`). Leave off for servers that merely accept encryption, which connect in clear text like Oracle's own clients. | +| **Native network encryption** | Off | Advanced. Turn on only if the server requires encrypted traffic (`SQLNET.ENCRYPTION_SERVER = REQUIRED`). Leave off for servers that merely accept encryption, which connect in clear text like Oracle's own clients. Some servers, including Oracle 11g, accept but never complete native encryption; with this on, the connect times out after 30 seconds and tells you to turn it off. | ## Example Configurations @@ -151,4 +151,6 @@ Columns with types not yet supported render as `` rather than **Connection dropped during handshake**: The server closed the connection mid-login. The error dialog shows the handshake phase it stopped at (for example `advancedNegotiation`, `dataTypeNegotiation`, or `authentication`). Check for a firewall, VPN, or proxy that resets traffic, and confirm the host and port reach the listener directly. If the phase is around negotiation and the server requires native network encryption, turn on the **Native network encryption** option; if you turned it on for a server that only accepts encryption, turn it back off. +**Login handshake timed out / native network encryption not completed**: The TCP connection is accepted but login never finishes. The connect now stops after 30 seconds instead of waiting for the server to reset. The common cause is the **Native network encryption** option turned on against a server that does not complete it, such as Oracle 11g; turn it off and connect again. Plain connections to those servers still work. For encryption in transit, use TLS by setting an SSL mode in the SSL settings. + **Limitations**: Username/password only, BFILE shows locator metadata only (content fetch via DBMS_LOB not supported), PL/SQL limited to anonymous blocks.