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 @@ -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

Expand Down
5 changes: 5 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ let package = Package(
name: "TableProSyncTests",
dependencies: ["TableProSync", "TableProModels"],
path: "Tests/TableProSyncTests"
),
.testTarget(
name: "TableProPluginKitTests",
dependencies: ["TableProPluginKit"],
path: "Tests/TableProPluginKitTests"
)
]
)
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Original file line number Diff line number Diff line change
@@ -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
))
}
}
72 changes: 59 additions & 13 deletions Plugins/OracleDriverPlugin/OracleConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
case authVerifierUnsupported(flag: String)
case authVersionNotSupported
case authConnectionDropped(phase: String?)
case loginTimedOut
case nativeEncryptionFailed
}

let message: String
Expand Down Expand Up @@ -173,18 +175,21 @@
tls: tls
)
config.nativeNetworkEncryption = nativeNetworkEncryption
let connectConfig = config

let connectionId = Self.connectionCounter.withLock { state -> Int in
state += 1
return state
}

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

Check failure on line 190 in Plugins/OracleDriverPlugin/OracleConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

reference to property 'nioLogger' in closure requires explicit use of 'self' to make capture semantics explicit

Check failure on line 190 in Plugins/OracleDriverPlugin/OracleConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

reference to property 'nioLogger' in closure requires explicit use of 'self' to make capture semantics explicit
)
}

state.withLock { current in
current.nioConnection = connection
Expand All @@ -193,18 +198,18 @@

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"
osLogger.error("Oracle connection failed at phase \(phase, privacy: .public) (\(sqlError.code.description, privacy: .public)): \(detail)")
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)")
Expand All @@ -219,19 +224,56 @@
}
}

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)
Expand All @@ -250,6 +292,10 @@
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
}
Expand Down
22 changes: 22 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions Plugins/TableProPluginKit/AsyncTimeout.swift
Original file line number Diff line number Diff line change
@@ -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<T: Sendable>(
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
Comment on lines +21 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make the timeout independent of child cancellation

For a stalled operation that does not observe task cancellation, this helper still waits past the deadline: the timeout child throws, cancelAll() runs, but exiting a withThrowingTaskGroup waits for the still-running operation child to finish. The new test only covers Task.sleep, which is cancellable, so it misses the exact Oracle login-stall case if OracleNIO.connect is blocked on I/O and does not complete on cancellation; users can still wait until the server reset instead of getting the 30s error.

Useful? React with 👍 / 👎.

}
}
15 changes: 15 additions & 0 deletions Plugins/TableProPluginKit/OracleConnectErrorClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +33 to +34

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 Don't map server login errors to encryption failures

When native encryption is enabled, this treats every generic .connectionFailed as a native-encryption failure. classify("server") is explicitly a .connectionFailed in the existing tests, so server-side login failures during connect (for example invalid credentials or unknown service names reported via serverDetail) can lose their real message and instead tell the user to turn encryption off. Limit this override to timeouts and known reset/dropped-handshake codes, or inspect the server detail before replacing it.

Useful? React with 👍 / 👎.

case .verifierUnsupported, .versionNotSupported:
return false
}
}
}
4 changes: 3 additions & 1 deletion docs/databases/oracle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -151,4 +151,6 @@ Columns with types not yet supported render as `<unsupported: type>` 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.
Loading