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 @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Cancelling a SQL Server connection now stops waiting right away instead of staying stuck until the attempt finished on its own. A connection attempt also gives up after the login timeout with a clear message rather than hanging, and for Windows Authentication the message points at the usual Kerberos causes (KDC unreachable, missing SPN, or clock skew). (#1889)
- Fixed MySQL and MariaDB queries ending in ORDER BY that could show an empty result with no error. A failure part way through reading rows was treated as a normal end of results, so a real server error looked like a table with no rows. TablePro now shows the error the server reported. (#1884)
- Fixed the query result row cap returning a single row when it was set to unlimited. (#1884)
- Fixed SSH tunnels that could accept a database connection and then go quiet instead of forwarding it or failing, which showed up as MySQL and MariaDB connections timing out while reading the server greeting. A tunnel that cannot open its forwarding channel now gives up after 10 seconds, closes the connection, and logs the reason, instead of leaving the driver to wait out its own timeout with no explanation. (#1883)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame.

**Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837).

**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369).
**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369).

**A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation

public final class SingleResumeGate<Value>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Value, Error>?
private var outcome: Result<Value, Error>?
private var settled = false

public init() {}

public func install(_ continuation: CheckedContinuation<Value, Error>, alreadyCancelled: Bool) {
lock.lock()
if alreadyCancelled, !settled {
settled = true
lock.unlock()
continuation.resume(throwing: CancellationError())
return
}
if let outcome {
lock.unlock()
continuation.resume(with: outcome)
return
}
self.continuation = continuation
lock.unlock()
}

public func win(_ value: Value) -> Bool {
settle(.success(value))
}

public func fail(_ error: Error) {
_ = settle(.failure(error))
}

@discardableResult
private func settle(_ result: Result<Value, Error>) -> Bool {
lock.lock()
if settled {
lock.unlock()
return false
}
settled = true
if let continuation {
self.continuation = nil
lock.unlock()
continuation.resume(with: result)
} else {
outcome = result
lock.unlock()
}
return true
}
}

public func runCancellableBlocking<T: Sendable>(
on queue: DispatchQueue,
deadline: DispatchTimeInterval? = nil,
deadlineQueue: DispatchQueue = .global(qos: .userInitiated),
timeoutError: @escaping @Sendable () -> Error = { CancellationError() },
work: @escaping @Sendable () throws -> T,
discardLateResult: @escaping @Sendable (T) -> Void = { _ in }
) async throws -> T {
let gate = SingleResumeGate<T>()
if let deadline {
deadlineQueue.asyncAfter(deadline: .now() + deadline) {
gate.fail(timeoutError())
}
}
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in
gate.install(continuation, alreadyCancelled: Task.isCancelled)
queue.async {
do {
let value = try work()
if !gate.win(value) {
discardLateResult(value)
}
} catch {
gate.fail(error)
}
}
}
} onCancel: {
gate.fail(CancellationError())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
public var applicationName: String
public var loginTimeoutSeconds: Int
public var authMethod: MSSQLAuthMethod
public var kerberosCachePath: String?

public static let defaultPort = 1433
public static let defaultSchema = "dbo"
Expand All @@ -28,11 +29,13 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag,
applicationName: String = MSSQLConnectionOptions.defaultApplicationName,
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds,
authMethod: MSSQLAuthMethod = .sqlServer
authMethod: MSSQLAuthMethod = .sqlServer,
kerberosCachePath: String? = nil
) {
self.host = host
self.port = port
self.authMethod = authMethod
self.kerberosCachePath = kerberosCachePath
switch authMethod {
case .sqlServer:
self.user = user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public enum MSSQLCoreError: LocalizedError, Sendable {
case cancelled
case tlsHandshakeFailed(kind: MSSQLTLSFailureKind, serverMessage: String)
case kerberosAuthFailed(kind: MSSQLKerberosFailureKind, serverMessage: String)
case connectionTimedOut(isKerberos: Bool)

public var errorDescription: String? {
switch self {
Expand All @@ -41,6 +42,11 @@ public enum MSSQLCoreError: LocalizedError, Sendable {
return String(format: String(localized: "TLS handshake failed: %@"), serverMessage)
case .kerberosAuthFailed(_, let serverMessage):
return String(format: String(localized: "Kerberos authentication failed: %@"), serverMessage)
case .connectionTimedOut(let isKerberos):
if isKerberos {
return String(localized: "Timed out completing Kerberos authentication. The Kerberos KDC (domain controller) may be unreachable, the server's SPN may be missing, or this Mac's clock may be off. Check your network to the domain, or use SQL Server Authentication.")
}
return String(localized: "Timed out connecting to the server. Check the host, port, and that the server is reachable and accepting connections.")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Foundation
import Testing
@testable import TableProMSSQLCore

private final class FlagBox: @unchecked Sendable {
private let lock = NSLock()
private var flagged = false
var value: Bool {
lock.lock(); defer { lock.unlock() }
return flagged
}
func mark() {
lock.lock(); flagged = true; lock.unlock()
}
}

private func pollUntil(_ condition: @Sendable () -> Bool, timeout: TimeInterval = 2) async {
let start = Date()
while !condition() {
if Date().timeIntervalSince(start) > timeout { return }
try? await Task.sleep(nanoseconds: 5_000_000)
}
}

private struct TimeoutError: Error {}

@Suite("Connect cancellation")
struct MSSQLConnectCancellationTests {
@Test("Work that completes normally returns its value")
func normalCompletion() async throws {
let queue = DispatchQueue(label: "test.normal")
let value = try await runCancellableBlocking(on: queue, work: { 42 })
#expect(value == 42)
}

@Test("Cancel returns promptly and the late result is discarded, not adopted")
func cancelDiscardsLateResult() async {
let queue = DispatchQueue(label: "test.cancel")
let workStarted = DispatchSemaphore(value: 0)
let release = DispatchSemaphore(value: 0)
let discarded = FlagBox()

let task = Task {
try await runCancellableBlocking(
on: queue,
work: { () -> Int in
workStarted.signal()
release.wait()
return 7
},
discardLateResult: { _ in discarded.mark() }
)
}

workStarted.wait()
task.cancel()

let result = await task.result
if case .success = result {
Issue.record("expected the cancelled caller to throw")
}
#expect(!discarded.value)

release.signal()
await pollUntil { discarded.value }
#expect(discarded.value)
}

@Test("A deadline fails the caller and discards the late result")
func deadlineFires() async {
let queue = DispatchQueue(label: "test.deadline")
let release = DispatchSemaphore(value: 0)
let discarded = FlagBox()

let result = await Task {
try await runCancellableBlocking(
on: queue,
deadline: .milliseconds(40),
timeoutError: { TimeoutError() },
work: { () -> Int in
release.wait()
return 1
},
discardLateResult: { _ in discarded.mark() }
)
}.result

switch result {
case .failure(let error):
#expect(error is TimeoutError)
case .success:
Issue.record("expected the deadline to fire")
}

release.signal()
await pollUntil { discarded.value }
#expect(discarded.value)
}

@Test("Work that completes before any cancel adopts the result")
func winnerAdopts() async throws {
let queue = DispatchQueue(label: "test.winner")
let discarded = FlagBox()
let value = try await runCancellableBlocking(
on: queue,
work: { 99 },
discardLateResult: { _ in discarded.mark() }
)
#expect(value == 99)
#expect(!discarded.value)
}

@Test("Racing fast work against immediate cancel never double-resumes")
func raceNeverDoubleResumes() async {
for _ in 0..<300 {
let queue = DispatchQueue(label: "test.race")
let discardCount = CountBox()
let task = Task {
try await runCancellableBlocking(
on: queue,
work: { 1 },
discardLateResult: { _ in discardCount.increment() }
)
}
task.cancel()
_ = await task.result
#expect(discardCount.value <= 1)
}
}
}

private final class CountBox: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var value: Int {
lock.lock(); defer { lock.unlock() }
return count
}
func increment() {
lock.lock(); count += 1; lock.unlock()
}
}
Loading
Loading