diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe659ca4..5a914d9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Refreshing a table no longer fails with "Query cancelled" on every second click. The refresh was sending the database a cancel for a row count that had already finished, and that cancel then aborted the reload it had just started. Covers PostgreSQL, Redshift, CockroachDB, MySQL, MariaDB, and Redis. (#2021) +- Stopping a query no longer shows it as a failed query with a red error message. A query you stopped is no longer recorded as a failure in query history either. +- Stopping a MySQL or MariaDB write, or a Redis command, no longer cancels the next statement you run on that connection. + ## [0.63.0] - 2026-08-05 ### Added diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 3f556a692..a632c4452 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -162,10 +162,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let queryTimeoutSeconds: Int private let stateLock = NSLock() + private let cancellationGate = PluginQueryCancellationGate() private var _isConnected: Bool = false private var _isShuttingDown: Bool = false private var _cachedServerVersion: String? - private var _isCancelled: Bool = false var isConnected: Bool { stateLock.lock() @@ -379,9 +379,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { // MARK: - Query Cancellation func cancelCurrentQuery() { - stateLock.lock() - _isCancelled = true - stateLock.unlock() + guard cancellationGate.cancel() != nil else { return } guard let mysql = mysql else { return } killQueryOnServer(threadId: mysql_thread_id(mysql)) @@ -420,14 +418,6 @@ final class MariaDBPluginConnection: @unchecked Sendable { mysql_close(killConn) } - private func consumeCancellation() -> Bool { - stateLock.lock() - defer { stateLock.unlock() } - guard _isCancelled else { return false } - _isCancelled = false - return true - } - private static func isExpectedInterruption(errno: UInt32, wasTruncated: Bool) -> Bool { wasTruncated && errno == UInt32(ER_QUERY_INTERRUPTED) } @@ -462,6 +452,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { throw MariaDBPluginError.notConnected } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } + let queryStatus = query.withCString { queryPtr in mysql_real_query(mysql, queryPtr, UInt(query.utf8.count)) } @@ -528,7 +521,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { var truncated = false while let rowPtr = mysql_fetch_row(resultPtr) { - if consumeCancellation() { + if cancellationGate.isCancelled(generation) { while mysql_fetch_row(resultPtr) != nil {} mysql_free_result(resultPtr) throw CancellationError() @@ -573,7 +566,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { while mysql_fetch_row(resultPtr) != nil {} } - if consumeCancellation() { + if cancellationGate.isCancelled(generation) { mysql_free_result(resultPtr) throw CancellationError() } @@ -677,7 +670,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { columnTypes: [UInt32], columnTypeNames: [String], columnIsBinary: [Bool], - rowCap: Int? = nil + rowCap: Int? = nil, + generation: Int ) throws -> (rows: [[PluginCellValue]], isTruncated: Bool) { let numFields = columns.count var resultBinds: [MYSQL_BIND] = Array(repeating: MYSQL_BIND(), count: numFields) @@ -722,7 +716,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { throw getStmtError(stmt) } - if consumeCancellation() { + if cancellationGate.isCancelled(generation) { throw CancellationError() } @@ -788,6 +782,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { throw MariaDBPluginError.notConnected } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } + guard let stmt = mysql_stmt_init(mysql) else { throw MariaDBPluginError(code: 0, message: "Failed to initialize prepared statement", sqlState: nil) } @@ -878,7 +875,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { let fetchResult = try fetchResultSet( from: stmt, metadata: metadata, columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, - columnIsBinary: columnIsBinary, rowCap: rowCap + columnIsBinary: columnIsBinary, rowCap: rowCap, generation: generation ) return MariaDBPluginQueryResult( diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 4536c2a89..dfcc791a3 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -102,11 +102,11 @@ final class LibPQPluginConnection: @unchecked Sendable { private let suppressServerSideCancel: Bool private let stateLock = NSLock() + private let cancellationGate = PluginQueryCancellationGate() private var _isConnected: Bool = false private var _isShuttingDown: Bool = false private var _cachedServerVersion: String? private var _cachedServerVersionNumber: Int32 = 0 - private var _isCancelled: Bool = false private var _isConnectCancelled: Bool = false private var _postgisOidMap: [UInt32: String] = [:] @@ -356,8 +356,9 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Query Cancellation func cancelCurrentQuery() { + guard cancellationGate.cancel() != nil else { return } + stateLock.lock() - _isCancelled = true let currentConn = conn stateLock.unlock() @@ -416,6 +417,9 @@ final class LibPQPluginConnection: @unchecked Sendable { throw LibPQPluginError.notConnected } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } + let localQuery = String(query) let result: OpaquePointer? = localQuery.withCString { queryPtr in PQexec(conn, queryPtr) @@ -444,11 +448,12 @@ final class LibPQPluginConnection: @unchecked Sendable { case PGRES_TUPLES_OK: defer { PQclear(result) } - return try fetchResults(from: result) + return try fetchResults(from: result, generation: generation) default: let error = getResultError(from: result) PQclear(result) + if cancellationGate.isCancelled(generation) { throw CancellationError() } throw error } } @@ -462,6 +467,9 @@ final class LibPQPluginConnection: @unchecked Sendable { throw LibPQPluginError.notConnected } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } + var paramValues: [UnsafePointer?] = [] var paramLengths: [Int32] = [] var paramFormats: [Int32] = [] @@ -547,11 +555,12 @@ final class LibPQPluginConnection: @unchecked Sendable { case PGRES_TUPLES_OK: defer { PQclear(result) } - return try fetchResults(from: result) + return try fetchResults(from: result, generation: generation) default: let error = getResultError(from: result) PQclear(result) + if cancellationGate.isCancelled(generation) { throw CancellationError() } throw error } } @@ -609,6 +618,9 @@ final class LibPQPluginConnection: @unchecked Sendable { return } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } + while let res = PQgetResult(conn) { PQclear(res) } let sendOk = queryToRun.withCString { queryPtr in @@ -675,31 +687,12 @@ final class LibPQPluginConnection: @unchecked Sendable { row.reserveCapacity(numFields) for colIndex in 0.. LibPQPluginQueryResult { + private func fetchResults(from result: OpaquePointer, generation: Int) throws -> LibPQPluginQueryResult { let metadata = readColumnMetadata(from: result) let parsed = try parseRows( from: result, columns: metadata.columns, columnOids: metadata.columnOids, - columnTypeNames: metadata.columnTypeNames + columnTypeNames: metadata.columnTypeNames, + generation: generation ) let oidMap = postgisOidMap @@ -882,11 +880,41 @@ final class LibPQPluginConnection: @unchecked Sendable { return converted } + private static func decodeCell( + from result: OpaquePointer, + row: Int32, + column: Int32, + oid: UInt32 + ) -> PluginCellValue { + guard PQgetisnull(result, row, column) != 1, + let valuePtr = PQgetvalue(result, row, column) else { + return .null + } + + let length = Int(PQgetlength(result, row, column)) + let bufferPtr = UnsafeRawBufferPointer(start: valuePtr, count: length) + + if oid == 17 { + let text = String(bytes: bufferPtr, encoding: .utf8) ?? "" + guard let data = LibPQByteaDecoder.decode(text) else { return .text(text) } + return .bytes(data) + } + + if oid == 16 { + let str = String(bytes: bufferPtr, encoding: .utf8) ?? "" + return .text(str == "t" ? "true" : "false") + } + + if let str = String(bytes: bufferPtr, encoding: .utf8) { return .text(str) } + return .text(String(bytes: bufferPtr, encoding: .isoLatin1) ?? "") + } + private func parseRows( from result: OpaquePointer, columns: [String], columnOids: [UInt32], - columnTypeNames: [String] + columnTypeNames: [String], + generation: Int ) throws -> LibPQPluginQueryResult { let numFields = columns.count let numRows = Int(PQntuples(result)) @@ -899,43 +927,20 @@ final class LibPQPluginConnection: @unchecked Sendable { rows.reserveCapacity(effectiveRowCount) for rowIndex in 0.. PluginQueryResult { let startTime = Date() - redisConnection?.resetCancellation() guard let conn = redisConnection else { throw RedisPluginError.notConnected @@ -122,7 +121,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Schema Operations func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - redisConnection?.resetCancellation() guard let conn = redisConnection else { throw RedisPluginError.notConnected } @@ -327,7 +325,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Database Switching func switchDatabase(to database: String) async throws { - redisConnection?.resetCancellation() guard let conn = redisConnection else { throw RedisPluginError.notConnected } let dbIndex: Int if let idx = Int(database) { @@ -422,7 +419,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { query: String, continuation: AsyncThrowingStream.Continuation ) async throws { - redisConnection?.resetCancellation() guard let conn = redisConnection else { throw RedisPluginError.notConnected } diff --git a/Plugins/TableProPluginKit/PluginQueryCancellationGate.swift b/Plugins/TableProPluginKit/PluginQueryCancellationGate.swift new file mode 100644 index 000000000..b7a5b71f5 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginQueryCancellationGate.swift @@ -0,0 +1,40 @@ +import Foundation + +public final class PluginQueryCancellationGate: @unchecked Sendable { + private let lock = NSLock() + private var lastGeneration = 0 + private var activeGeneration: Int? + private var cancelledGeneration: Int? + + public init() {} + + public func beginQuery() -> Int { + lock.lock() + defer { lock.unlock() } + lastGeneration += 1 + activeGeneration = lastGeneration + return lastGeneration + } + + public func endQuery(_ generation: Int) { + lock.lock() + defer { lock.unlock() } + guard activeGeneration == generation else { return } + activeGeneration = nil + } + + @discardableResult + public func cancel() -> Int? { + lock.lock() + defer { lock.unlock() } + guard let generation = activeGeneration else { return nil } + cancelledGeneration = generation + return generation + } + + public func isCancelled(_ generation: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return cancelledGeneration == generation + } +} diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 27d1380f7..43f2dc917 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -129,15 +129,10 @@ final class PaginationCoordinator { // MARK: - Cancel Current Query func cancelCurrentQuery() { - let hadInFlightTask = parent.currentQueryTask != nil || parent.currentRowCountTask != nil - parent.currentQueryTask?.cancel() - parent.currentQueryTask = nil + parent.cancelInFlightQueryTask() parent.currentRowCountTask?.cancel() parent.currentRowCountTask = nil parent.queryGeneration += 1 - if hadInFlightTask, let driver = DatabaseManager.shared.driver(for: parent.connectionId) { - try? driver.cancelQuery() - } parent.toolbarState.setExecuting(false) for idx in parent.tabManager.tabs.indices { if parent.tabManager.tabs[idx].execution.isExecuting @@ -171,6 +166,7 @@ final class PaginationCoordinator { parent.tabManager.mutate(at: index) { $0.pagination.isCountingExact = true } + let capturedGeneration = parent.queryGeneration parent.currentRowCountTask = Task(priority: .userInitiated) { [parent] in let count = await Self.exactRowCount( connectionId: parent.connectionId, @@ -181,6 +177,8 @@ final class PaginationCoordinator { ) guard !Task.isCancelled else { return } + guard capturedGeneration == parent.queryGeneration else { return } + parent.currentRowCountTask = nil parent.tabManager.mutate(tabId: tabId) { tab in tab.pagination.isCountingExact = false guard let count, count >= 0 else { return } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index abcb40970..11f66928e 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -426,17 +426,20 @@ extension QueryExecutionCoordinator { return (plan, sql) } - let outcome: RowCountOutcome + let outcome: RowCountOutcome? switch prepared.plan { case .skip: - return + outcome = nil case .clear: outcome = .clear case .approximate: - guard let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in + if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in try await driver.fetchApproximateRowCount(table: tableName) - }) else { return } - outcome = .count(count, isApproximate: true) + }) { + outcome = .count(count, isApproximate: true) + } else { + outcome = nil + } case let .filteredNonSQL(filters, logicMode): if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk, { driver in try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) @@ -446,24 +449,28 @@ extension QueryExecutionCoordinator { outcome = .clear } case .exactCount: - guard let sql = prepared.sql else { return } let count: Int? - do { - count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk) { driver in - let result = try await driver.execute(query: sql) - guard let countStr = result.rows.first?.first?.asText else { return Int?.none } - return Int(countStr) + if let sql = prepared.sql { + do { + count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk) { driver in + let result = try await driver.execute(query: sql) + guard let countStr = result.rows.first?.first?.asText else { return Int?.none } + return Int(countStr) + } + } catch { + helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)") + count = nil } - } catch { - helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)") - return + } else { + count = nil } - guard let count else { return } - outcome = .count(count, isApproximate: false) + outcome = count.map { RowCountOutcome.count($0, isApproximate: false) } } await MainActor.run { guard capturedGeneration == parent.queryGeneration else { return } + parent.currentRowCountTask = nil + guard let outcome else { return } parent.tabManager.mutate(tabId: tabId) { tab in let applied = outcome.appliedTotal tab.pagination.totalRowCount = applied.total @@ -498,6 +505,14 @@ extension QueryExecutionCoordinator { connection conn: DatabaseConnection ) { parent.currentQueryTask = nil + guard !DatabaseCancellationDiagnosis.isCancellation(error) else { + parent.tabManager.mutate(tabId: tabId) { tab in + tab.execution.isExecuting = false + tab.pagination.isLoadingMore = false + } + parent.toolbarState.setExecuting(false) + return + } parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.errorMessage = DatabaseWriteRejectionDiagnosis.formatted(error) tab.execution.errorQuery = sql diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 7fd3173b7..b595232fb 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -176,7 +176,7 @@ extension QueryExecutionCoordinator { } parent.currentQueryTask = nil parent.toolbarState.setExecuting(false) - if error is CancellationError || Task.isCancelled { return } + if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { return } guard capturedGeneration == parent.queryGeneration else { return } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) } diff --git a/TablePro/Core/Database/DatabaseCancellationDiagnosis.swift b/TablePro/Core/Database/DatabaseCancellationDiagnosis.swift new file mode 100644 index 000000000..1fe7bc3c9 --- /dev/null +++ b/TablePro/Core/Database/DatabaseCancellationDiagnosis.swift @@ -0,0 +1,14 @@ +// +// DatabaseCancellationDiagnosis.swift +// TablePro +// + +import Foundation + +internal enum DatabaseCancellationDiagnosis { + static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 30de82b44..23090cb68 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1288,7 +1288,7 @@ final class MainContentCoordinator { } currentQueryTask = nil toolbarState.setExecuting(false) - if error is CancellationError || Task.isCancelled { return } + if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { return } guard capturedGeneration == queryGeneration else { return } if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected { pendingLoadTrigger = trigger @@ -1300,7 +1300,7 @@ final class MainContentCoordinator { } } - private func cancelInFlightQueryTask() { + internal func cancelInFlightQueryTask() { guard currentQueryTask != nil else { return } currentQueryTask?.cancel() do { diff --git a/TableProTests/Database/DatabaseCancellationDiagnosisTests.swift b/TableProTests/Database/DatabaseCancellationDiagnosisTests.swift new file mode 100644 index 000000000..319353b0f --- /dev/null +++ b/TableProTests/Database/DatabaseCancellationDiagnosisTests.swift @@ -0,0 +1,62 @@ +// +// DatabaseCancellationDiagnosisTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private struct FakeDriverError: PluginDriverError { + let pluginErrorMessage: String + let pluginErrorCode: Int? + let pluginSqlState: String? +} + +private struct PlainError: Error, LocalizedError { + var errorDescription: String? { "Something else went wrong" } +} + +@Suite("DatabaseCancellationDiagnosis") +struct DatabaseCancellationDiagnosisTests { + @Test("A Swift cancellation is recognised") + func recognisesSwiftCancellationError() { + #expect(DatabaseCancellationDiagnosis.isCancellation(CancellationError())) + } + + @Test("A Cocoa user cancellation is recognised, matching presentError:") + func recognisesCocoaUserCancelled() { + #expect(DatabaseCancellationDiagnosis.isCancellation(CocoaError(.userCancelled))) + #expect(DatabaseCancellationDiagnosis.isCancellation( + NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError) + )) + } + + @Test("A query timeout is not a cancellation, even though PostgreSQL reports 57014 for both") + func doesNotSwallowAQueryTimeout() { + let timeout = FakeDriverError( + pluginErrorMessage: "canceling statement due to statement timeout", + pluginErrorCode: nil, + pluginSqlState: "57014" + ) + + #expect(DatabaseCancellationDiagnosis.isCancellation(timeout) == false) + } + + @Test("An interrupted MySQL query is not swallowed on its error code alone") + func doesNotSwallowMySQLInterrupted() { + let interrupted = FakeDriverError( + pluginErrorMessage: "Query execution was interrupted", + pluginErrorCode: 1_317, + pluginSqlState: "70100" + ) + + #expect(DatabaseCancellationDiagnosis.isCancellation(interrupted) == false) + } + + @Test("An unrelated error is not a cancellation") + func ignoresAPlainError() { + #expect(DatabaseCancellationDiagnosis.isCancellation(PlainError()) == false) + } +} diff --git a/TableProTests/Plugins/PluginQueryCancellationGateTests.swift b/TableProTests/Plugins/PluginQueryCancellationGateTests.swift new file mode 100644 index 000000000..afa5f5198 --- /dev/null +++ b/TableProTests/Plugins/PluginQueryCancellationGateTests.swift @@ -0,0 +1,74 @@ +// +// PluginQueryCancellationGateTests.swift +// TableProTests +// + +@testable import TableProPluginKit +import Testing + +@Suite("Plugin query cancellation gate") +struct PluginQueryCancellationGateTests { + @Test("Cancelling while no query is running is a no-op") + func cancelWhileIdleReturnsNil() { + let gate = PluginQueryCancellationGate() + + #expect(gate.cancel() == nil) + } + + @Test("Cancelling while a query is running reports that query") + func cancelWhileActiveReturnsTheActiveGeneration() { + let gate = PluginQueryCancellationGate() + let generation = gate.beginQuery() + + #expect(gate.cancel() == generation) + #expect(gate.isCancelled(generation)) + } + + @Test("A cancel never reaches a query issued after it") + func cancelDoesNotLeakIntoTheNextQuery() { + let gate = PluginQueryCancellationGate() + let first = gate.beginQuery() + gate.cancel() + gate.endQuery(first) + + let second = gate.beginQuery() + + #expect(gate.isCancelled(first)) + #expect(gate.isCancelled(second) == false) + } + + @Test("Cancelling after a query finished is a no-op") + func cancelAfterEndQueryIsANoOp() { + let gate = PluginQueryCancellationGate() + let generation = gate.beginQuery() + gate.endQuery(generation) + + #expect(gate.cancel() == nil) + #expect(gate.isCancelled(generation) == false) + } + + @Test("A query returning no rows leaves nothing armed for the next one") + func aQueryThatConsumesNothingLeavesTheGateIdle() { + let gate = PluginQueryCancellationGate() + let first = gate.beginQuery() + gate.cancel() + gate.endQuery(first) + + let second = gate.beginQuery() + gate.endQuery(second) + + #expect(gate.cancel() == nil) + #expect(gate.isCancelled(second) == false) + } + + @Test("Ending a superseded query does not clear the running one") + func endQueryIgnoresAStaleGeneration() { + let gate = PluginQueryCancellationGate() + let first = gate.beginQuery() + let second = gate.beginQuery() + + gate.endQuery(first) + + #expect(gate.cancel() == second) + } +} diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 000791570..68df715ce 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -194,6 +194,42 @@ struct MainContentCoordinatorRefreshTests { } } + @Test("A finished row count leaves no handle that fakes an in-flight query") + func cancelWithStaleRowCountHandleDoesNotTouchDriver() { + withInjectedDriver { connection, driver in + let (coordinator, _) = makeCoordinator(connection: connection) + let finishedRowCount = Task {} + coordinator.currentRowCountTask = finishedRowCount + + coordinator.cancelCurrentQuery() + + #expect(driver.cancelQueryCallCount == 0) + #expect(coordinator.currentRowCountTask == nil) + } + } + + @Test("Refreshing an idle table tab twice never issues a stray driver cancel") + func repeatedIdleRefreshNeverCancelsDriver() { + withInjectedDriver { connection, driver in + let (coordinator, tabManager) = makeCoordinator(connection: connection) + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + + for _ in 0..<4 { + coordinator.currentRowCountTask = Task {} + coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) + coordinator.currentQueryTask?.cancel() + coordinator.currentQueryTask = nil + } + + #expect(driver.cancelQueryCallCount == 0) + } + } + @Test("cancelCurrentQuery cancels the driver when a query is in flight") func cancelWithInFlightCancelsDriver() { withInjectedDriver { connection, driver in