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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Setting in Settings > General to hide the version and Check for Updates on the Welcome window.
- Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed.
- Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504)
- Warning banner and Test Connection notice for a server warning at login, such as an Oracle password about to expire. (#3401)

### Changed

Expand Down Expand Up @@ -80,6 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background.
- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed.
- Settings > AI reading sign-in tokens and running command line tools for providers that were never added.
- Oracle connection timing out after 30 seconds when the password is about to expire. (#3401)
- Oracle session left open on the server when a connect was canceled during login.
- Only the first statement running on iPhone and iPad when the query holds several. (#3399)
- Query on iPhone and iPad appearing to do nothing after CREATE, DROP or a write that changed no rows, including every Oracle write. (#3399)
- SQLite on iPhone and iPad reporting a CHECK or UNIQUE violation as a saved row in Add Row, the row editor and Shortcuts. (#3399)
Expand Down
2 changes: 1 addition & 1 deletion Packages/TableProOracle/Package.resolved

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

2 changes: 1 addition & 1 deletion Packages/TableProOracle/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/TableProApp/oracle-nio",
revision: "6350a34f61aebb358358392d85c29e990dcd8c0e"
revision: "045a44674e9f9da3904e0f2b87995490a7cc88e4"
),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.29.0"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ public func withOracleTimeout<T: Sendable>(
/// The task group waits for the operation child even after the deadline fires,
/// so `onTimeout` must force the operation to complete (close a connection,
/// fail a promise) when the wrapped call does not respond to task cancellation.
///
/// An operation that ignores cancellation can still succeed after the deadline
/// fired or the caller was cancelled. Nobody receives that value, so it goes to
/// `onAbandoned`, which must release whatever it holds, such as an open session.
/// It is synchronous so that releasing never holds up the caller's error.
public func withOracleTimeout<T: Sendable>(
seconds: Double,
onTimeout: @escaping @Sendable () -> Void,
onAbandoned: @escaping @Sendable (T) -> Void = { _ in },
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
Expand All @@ -44,10 +50,20 @@ public func withOracleTimeout<T: Sendable>(
onTimeout()
throw OracleTimeoutError(seconds: seconds)
}
defer { group.cancelAll() }
guard let result = try await group.next() else {
throw OracleTimeoutError(seconds: seconds)
do {
guard let result = try await group.next() else {
throw OracleTimeoutError(seconds: seconds)
}
group.cancelAll()
return result
} catch {
group.cancelAll()
while !group.isEmpty {
if let late = try? await group.next() {
onAbandoned(late)
}
}
throw error
}
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public final class OracleCoreConnection: @unchecked Sendable {
var capturesServerOutput = false
var close = OracleCloseRecord()
var serverRelease: OracleServerRelease?
var loginWarning: String?
/// Read before the first statement of each session, and again before the statement after one that can
/// change them, so no result ever waits on them mid-stream.
var sessionZones: OracleSessionTimeZones?
Expand All @@ -124,6 +125,12 @@ public final class OracleCoreConnection: @unchecked Sendable {
state.withLock { $0.serverRelease }
}

/// The warning the server attached to the last login that succeeded, such as ORA-28002 for a password in
/// its grace period, in the server's own words. The session is usable; the warning is for the user to read.
public var loginWarning: String? {
state.withLock { $0.loginWarning }
}

public init(options: OracleConnectionOptions) {
self.options = options
}
Expand Down Expand Up @@ -161,29 +168,29 @@ public final class OracleCoreConnection: @unchecked Sendable {
return counter
}

let attempt = OSAllocatedUnfairLock(initialState: false)

do {
// The driver's connect does not observe task cancellation, so a login the caller
// cancelled, or the backstop gave up on, still completes. Nothing downstream will
// ever see that handle, so it closes its own session rather than leaving one open
// on the server per abandoned attempt. Closing logs off and waits for the server's
// answer, so it runs on its own, like every other close here.
let connection = try await withOracleTimeout(
seconds: options.loginTimeoutSeconds + Self.loginTimeoutGraceSeconds,
onTimeout: { attempt.withLock { $0 = true } }
onTimeout: {},
onAbandoned: { connection in
Task {
try? await connection.close()
osLogger.notice(
"Closed the Oracle connection: \(OracleDisconnectReason.abandonedLoginAttempt.logDescription, privacy: .public)"
)
}
}
) {
let connection = try await OracleNIO.OracleConnection.connect(
try await OracleNIO.OracleConnection.connect(
configuration: connectConfig,
id: connectionId,
logger: connectLogger
)
// A connect that lands after the backstop fired has already been given
// up on, and nothing downstream will ever see this handle. It closes its
// own socket rather than leaking one per abandoned attempt.
guard !attempt.withLock({ $0 }) else {
try? await connection.close()
osLogger.notice(
"Closed the Oracle connection: \(OracleDisconnectReason.abandonedLoginAttempt.logDescription, privacy: .public)"
)
throw OracleCoreError.loginTimedOut
}
return connection
}

let release = OracleServerRelease(
Expand All @@ -201,6 +208,7 @@ public final class OracleCoreConnection: @unchecked Sendable {
current.isConnected = true
current.hasEverConnected = true
current.serverRelease = release
current.loginWarning = connection.loginWarning?.message
current.sessionZones = nil
current.close.clearOnConnect()
return true
Expand All @@ -216,6 +224,8 @@ public final class OracleCoreConnection: @unchecked Sendable {
} catch is OracleTimeoutError {
osLogger.error("Oracle login handshake timed out after \(self.options.loginTimeoutSeconds, privacy: .public)s")
throw OracleCoreError.loginTimedOut
} catch let cancellation as CancellationError {
throw cancellation
} catch let sqlError as OracleSQLError {
throw connectError(from: sqlError)
} catch let nioSslError as NIOSSLError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,64 @@ final class OracleAsyncTimeoutTests: XCTestCase {
"onTimeout must release an operation that ignores cancellation, or the task group never unwinds"
)
}

/// A login does not observe task cancellation, so it can finish after its caller gave up. That
/// session reaches nobody, and only `onAbandoned` can close it.
func testValueArrivingAfterCancellationGoesToOnAbandoned() async {
let abandoned = Counter()
let task = Task {
try await withOracleTimeout(
seconds: 5,
onTimeout: {},
onAbandoned: { _ in abandoned.increment() },
operation: {
await finishIgnoringCancellation(after: .milliseconds(200))
return "late"
}
)
}
try? await Task.sleep(nanoseconds: 20_000_000)
task.cancel()

switch await task.result {
case .success:
XCTFail("a cancelled call must not return the late value")
case .failure(let error):
XCTAssertTrue(error is CancellationError, "expected CancellationError, got \(error)")
}
XCTAssertEqual(abandoned.value, 1)
}

func testValueArrivingAfterTheDeadlineGoesToOnAbandoned() async {
let abandoned = Counter()
do {
_ = try await withOracleTimeout(
seconds: 0.05,
onTimeout: {},
onAbandoned: { _ in abandoned.increment() },
operation: {
await finishIgnoringCancellation(after: .milliseconds(300))
return "late"
}
)
XCTFail("expected the deadline to fire")
} catch {
XCTAssertTrue(error is OracleTimeoutError, "expected OracleTimeoutError, got \(error)")
}
XCTAssertEqual(abandoned.value, 1)
}

func testValueReturnedInTimeIsNotAbandoned() async throws {
let abandoned = Counter()
let result = try await withOracleTimeout(
seconds: 5,
onTimeout: {},
onAbandoned: { _ in abandoned.increment() },
operation: { "done" }
)
XCTAssertEqual(result, "done")
XCTAssertEqual(abandoned.value, 0)
}
}

private final class Counter: @unchecked Sendable {
Expand All @@ -85,3 +143,11 @@ private final class Counter: @unchecked Sendable {
lock.unlock()
}
}

private func finishIgnoringCancellation(after delay: DispatchTimeInterval) async {
await withCheckedContinuation { continuation in
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
continuation.resume()
}
}
}
2 changes: 1 addition & 1 deletion Plugins/BeancountDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Beancount</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/BigQueryDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.42.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/CSVExportPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesExportFormatIds</key>
<array>
<string>csv</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/CSVImportPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesImportFormatIds</key>
<array>
<string>csv</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/CassandraDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
<key>NSPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).CassandraPlugin</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/ClickHouseDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>ClickHouse</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/CloudflareD1DriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.42.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/CloudflareR2SQLDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Cloudflare R2 SQL</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/DamengDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Dameng</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/DuckDBDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/DynamoDBDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.42.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/ElasticsearchDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.53.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/EtcdDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.42.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Plugins/FirebaseDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<key>TableProMinAppVersion</key>
<string>0.80.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Firestore</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/HanaDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>SAP HANA</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/JSONExportPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesExportFormatIds</key>
<array>
<string>json</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/JSONImportPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesImportFormatIds</key>
<array>
<string>json</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/KafkaDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Kafka</string>
Expand Down
2 changes: 1 addition & 1 deletion Plugins/LibSQLDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<key>TableProMinAppVersion</key>
<string>0.42.0</string>
<key>TableProPluginKitVersion</key>
<integer>37</integer>
<integer>38</integer>
</dict>
</plist>
Loading
Loading