diff --git a/CHANGELOG.md b/CHANGELOG.md index 7206434a22..165b2bb94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Query Live Activity still counting up on the iOS Lock Screen and Dynamic Island after the app was quit mid-query. +- Stop leaving an iOS MySQL or Redis query running, with the spinner and the Live Activity stuck behind it. +- A stopped or memory-stopped iOS query recorded in Query History as successful. +- Query Live Activity marked interrupted while an iOS query longer than five minutes was still running. - Connection screen stuck on Connecting for good after a cancelled connect on iPhone and iPad. - Edited connection host, port or credentials ignored until relaunch on iPhone and iPad. - SSH tunnel handshake with no timeout on iPhone and iPad, against a server that accepts TCP and then stalls. diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index c9a03700cc..e3f81f0499 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -132,14 +132,16 @@ public final class ConnectionManager: @unchecked Sendable { !suspensionBlockingIds().isEmpty } - public func releaseSuspensionBlockingResources() async { + @discardableResult + public func releaseSuspensionBlockingResources() async -> [UUID] { let ids = suspensionBlockingIds() - guard !ids.isEmpty else { return } + guard !ids.isEmpty else { return [] } await withTaskGroup(of: Void.self) { group in for id in ids { group.addTask { await self.disconnect(id) } } } + return ids } private func suspensionBlockingIds() -> [UUID] { diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 4a9f3257ff..dc5dbf53c6 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -33,6 +33,7 @@ final class AppState { var pendingImportURL: URL? let connectionManager: ConnectionManager let backgroundRelease: BackgroundReleaseCoordinator + let queryActivities = QueryActivityController() let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider let secureStore: KeychainSecureStore diff --git a/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift b/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift new file mode 100644 index 0000000000..01fb52c6d7 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift @@ -0,0 +1,55 @@ +import Foundation +import TableProModels + +nonisolated enum QueryExecutionOutcome: Equatable, Sendable { + case completed + case failed + case stopped + case interrupted + + init(phase: QueryEditorViewModel.Phase) { + switch phase { + case .finished: + self = .completed + case .error: + self = .failed + case .truncated(let reason): + self = Self(truncation: reason) + case .idle, .running: + self = .interrupted + } + } + + private init(truncation: TruncationReason) { + switch truncation { + case .rowCap, .driverLimit: + self = .completed + case .cancelled: + self = .stopped + case .memoryPressure: + self = .interrupted + @unknown default: + self = .interrupted + } + } + + var activityOutcome: QueryActivityAttributes.Outcome { + switch self { + case .completed: .completed + case .failed: .failed + case .stopped: .stopped + case .interrupted: .interrupted + } + } + + var historyMessage: String? { + switch self { + case .completed, .failed: + nil + case .stopped: + String(localized: "Stopped before the query finished.") + case .interrupted: + String(localized: "Interrupted before the query finished.") + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift b/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift index 6ff8b92be0..ff35d4fda5 100644 --- a/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift +++ b/TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift @@ -40,17 +40,19 @@ final class BackgroundReleaseCoordinator { syncAssertion() } - func releaseForSuspension() async { + @discardableResult + func releaseForSuspension() async -> [UUID] { isPreparedForSuspension = false guard connectionManager.hasSuspensionBlockingResources else { syncAssertion() - return + return [] } beginAssertion() releasesInFlight += 1 - await connectionManager.releaseSuspensionBlockingResources() + let released = await connectionManager.releaseSuspensionBlockingResources() releasesInFlight -= 1 syncAssertion() + return released } private var needsAssertion: Bool { diff --git a/TableProMobile/TableProMobile/Platform/QueryActivityController.swift b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift new file mode 100644 index 0000000000..9e798dc136 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift @@ -0,0 +1,240 @@ +import ActivityKit +import Foundation +import os +import UIKit + +@MainActor +protocol LiveActivityHandle { + var id: String { get } + var state: QueryActivityAttributes.ContentState { get } + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async + func end(state: QueryActivityAttributes.ContentState) async +} + +@MainActor +protocol LiveActivityStore { + var areActivitiesEnabled: Bool { get } + var liveActivities: [any LiveActivityHandle] { get } + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle +} + +struct QueryExecutionToken: Hashable, Sendable { + fileprivate let id: UUID + + fileprivate init() { + id = UUID() + } +} + +@MainActor +final class QueryActivityController { + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryActivity") + private static let taskName = "End query Live Activity" + private static let heartbeatInterval = QueryActivityStaleWindow.seconds / 2 + + private struct Execution { + let connectionId: UUID + let handle: any LiveActivityHandle + var lastUpdatedAt: Date + } + + private let store: any LiveActivityStore + private let asserter: any BackgroundTaskAsserting + private let now: () -> Date + + private var executions: [QueryExecutionToken: Execution] = [:] + private var taskIdentifier: UIBackgroundTaskIdentifier = .invalid + private var assertionHolders = 0 + private var reapTask: Task? + + init( + store: any LiveActivityStore = ActivityKitLiveActivityStore(), + asserter: any BackgroundTaskAsserting = UIApplication.shared, + now: @escaping () -> Date = Date.init + ) { + self.store = store + self.asserter = asserter + self.now = now + } + + var ownedActivityIds: Set { + Set(executions.values.map(\.handle.id)) + } + + func reapOrphans() async { + if let inFlight = reapTask { + return await inFlight.value + } + let task = Task { await self.endOrphans() } + reapTask = task + await task.value + reapTask = nil + } + + func start( + connectionId: UUID, + connectionName: String, + query: String, + startedAt: Date + ) async -> QueryExecutionToken? { + await reapOrphans() + guard store.areActivitiesEnabled else { return nil } + + let attributes = QueryActivityAttributes( + connectionId: connectionId, + connectionName: connectionName, + queryPreview: preview(for: query) + ) + let state = QueryActivityAttributes.ContentState(startedAt: startedAt, lastUpdatedAt: startedAt) + do { + let handle = try store.request( + attributes: attributes, + state: state, + staleDate: startedAt.addingTimeInterval(QueryActivityStaleWindow.seconds) + ) + let token = QueryExecutionToken() + executions[token] = Execution(connectionId: connectionId, handle: handle, lastUpdatedAt: startedAt) + return token + } catch { + Self.logger.warning("Could not start the query Live Activity: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + func update(token: QueryExecutionToken?, rowsStreamed: Int) async { + guard let token, let execution = executions[token] else { return } + let instant = now() + let rowsChanged = execution.handle.state.rowsStreamed != rowsStreamed + let heartbeatDue = instant.timeIntervalSince(execution.lastUpdatedAt) >= Self.heartbeatInterval + guard rowsChanged || heartbeatDue else { return } + + var state = execution.handle.state + state.rowsStreamed = rowsStreamed + state.lastUpdatedAt = instant + executions[token]?.lastUpdatedAt = instant + await execution.handle.update( + state: state, + staleDate: instant.addingTimeInterval(QueryActivityStaleWindow.seconds) + ) + } + + func end(token: QueryExecutionToken?, outcome: QueryActivityAttributes.Outcome) async { + guard let token, let execution = executions.removeValue(forKey: token) else { return } + await end(execution: execution, outcome: outcome) + } + + func endEverything(forConnection connectionId: UUID, outcome: QueryActivityAttributes.Outcome) async { + let matching = executions.filter { $0.value.connectionId == connectionId } + guard !matching.isEmpty else { return } + for token in matching.keys { + executions.removeValue(forKey: token) + } + for execution in matching.values { + await end(execution: execution, outcome: outcome) + } + } + + private func end(execution: Execution, outcome: QueryActivityAttributes.Outcome) async { + beginAssertion() + defer { endAssertion() } + await execution.handle.end(state: execution.handle.state.ended(as: outcome, at: now())) + } + + // MARK: - Orphans + + private func endOrphans() async { + let owned = ownedActivityIds + let orphans = store.liveActivities.filter { !owned.contains($0.id) } + guard !orphans.isEmpty else { return } + Self.logger.info("Ending \(orphans.count, privacy: .public) orphaned query Live Activities") + beginAssertion() + defer { endAssertion() } + for orphan in orphans { + await orphan.end(state: orphan.state.ended(as: .interrupted, at: now())) + } + } + + // MARK: - Privacy + + private func preview(for query: String) -> String { + guard !AppPreferences.hidesQueryPreviewInActivity else { + return String(localized: "Running query") + } + return String(query.prefix(60)) + } + + // MARK: - Background assertion + + private func beginAssertion() { + assertionHolders += 1 + guard taskIdentifier == .invalid else { return } + taskIdentifier = asserter.beginBackgroundTask(name: Self.taskName) { [weak self] in + self?.expireAssertion() + } + } + + private func endAssertion() { + assertionHolders = max(0, assertionHolders - 1) + guard assertionHolders == 0 else { return } + releaseAssertion() + } + + private func expireAssertion() { + Self.logger.warning("Background time expired before the query Live Activity was ended") + assertionHolders = 0 + releaseAssertion() + } + + private func releaseAssertion() { + guard taskIdentifier != .invalid else { return } + asserter.endBackgroundTask(taskIdentifier) + taskIdentifier = .invalid + } +} + +// MARK: - ActivityKit + +@MainActor +struct ActivityKitLiveActivityStore: LiveActivityStore { + var areActivitiesEnabled: Bool { + ActivityAuthorizationInfo().areActivitiesEnabled + } + + var liveActivities: [any LiveActivityHandle] { + Activity.activities.map(ActivityKitHandle.init) + } + + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: state, staleDate: staleDate) + ) + return ActivityKitHandle(activity: activity) + } +} + +@MainActor +private struct ActivityKitHandle: LiveActivityHandle { + let activity: Activity + + var id: String { activity.id } + var state: QueryActivityAttributes.ContentState { activity.content.state } + + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async { + nonisolated(unsafe) let target = activity + await target.update(.init(state: state, staleDate: staleDate)) + } + + func end(state: QueryActivityAttributes.ContentState) async { + nonisolated(unsafe) let target = activity + await target.end(.init(state: state, staleDate: nil), dismissalPolicy: .immediate) + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index ca85ff66ca..930dc9bcb0 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -70,6 +70,7 @@ struct TableProMobileApp: App { lockState.handleScenePhase(phase) switch phase { case .active: + Task { await appState.queryActivities.reapOrphans() } appState.backgroundRelease.cancelPreparation() MemoryPressureMonitor.shared.start() appState.retryLoadIfFailed() @@ -98,7 +99,12 @@ struct TableProMobileApp: App { heartbeatTask?.cancel() heartbeatTask = nil heartbeatService = nil - Task { await appState.backgroundRelease.releaseForSuspension() } + Task { + let released = await appState.backgroundRelease.releaseForSuspension() + for connectionId in released { + await appState.queryActivities.endEverything(forConnection: connectionId, outcome: .interrupted) + } + } scheduleBackgroundSync() default: break diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index eb8c5e3be8..42e93ca3f9 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -6,7 +6,7 @@ import TableProModels @MainActor @Observable final class QueryEditorViewModel { - enum Phase: Sendable { + nonisolated enum Phase: Sendable { case idle case running case finished @@ -105,6 +105,8 @@ final class QueryEditorViewModel { } func stop() { + guard case .running = phase else { return } + buffer.markTruncated(.cancelled) fetchTask?.cancel() } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 29f27856aa..6688a72c3e 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -1,4 +1,3 @@ -import ActivityKit import os import SwiftUI import TableProDatabase @@ -7,6 +6,7 @@ import TableProQuery struct QueryEditorView: View { @Environment(ConnectionCoordinator.self) private var coordinator + @Environment(AppState.self) private var appState private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") @@ -128,6 +128,7 @@ struct QueryEditorView: View { Button { if isExecuting { executeTask?.cancel() + viewModel.stop() Task { try? await session?.driver.cancelCurrentQuery() } } else { executeTask = Task { await executeQuery() } @@ -423,108 +424,79 @@ struct QueryEditorView: View { isExecuting = true let startedAt = Date() executionStartTime = startedAt - let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt) - let progressUpdater = startActivityProgressUpdater(activity: activity, startedAt: startedAt) - defer { - progressUpdater.cancel() + appError = nil + + let token = await appState.queryActivities.start( + connectionId: connectionId, + connectionName: coordinator.displayName, + query: trimmed, + startedAt: startedAt + ) + + guard !Task.isCancelled else { isExecuting = false executionStartTime = nil - endQueryActivity(activity, startedAt: startedAt) + await appState.queryActivities.end(token: token, outcome: .stopped) + recordHistory(query: trimmed, outcome: .stopped, errorMessage: QueryExecutionOutcome.stopped.historyMessage) + return } - appError = nil + + let progressUpdater = startActivityProgressUpdater(token: token) await viewModel.run(driver: session.driver, query: trimmed) - if case .error(let err) = viewModel.phase { + progressUpdater.cancel() + let phase = viewModel.phase + let outcome = QueryExecutionOutcome(phase: phase) + let elapsed = viewModel.executionTime + isExecuting = false + executionStartTime = nil + + await appState.queryActivities.end(token: token, outcome: outcome.activityOutcome) + + if case .error(let err) = phase { appError = err hapticError.toggle() - coordinator.addHistoryItem( - QueryHistoryItem( - query: trimmed, - connectionId: connectionId, - wasSuccessful: false, - errorMessage: err.localizedDescription - ) - ) + recordHistory(query: trimmed, outcome: outcome, errorMessage: err.localizedDescription) + return + } + + executionTime = elapsed + + guard outcome == .completed else { + recordHistory(query: trimmed, outcome: outcome, errorMessage: outcome.historyMessage) return } - executionTime = viewModel.executionTime hapticSuccess.toggle() IOSAnalyticsProvider.shared.markFirstQueryExecuted() - let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) - coordinator.addHistoryItem(item) + recordHistory(query: trimmed, outcome: outcome, errorMessage: nil) } - // MARK: - Live Activity - - private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } - let preview: String = AppPreferences.hidesQueryPreviewInActivity - ? String(localized: "Running query") - : String(trimmed.prefix(60)) - let attributes = QueryActivityAttributes( - connectionId: coordinator.connection.id, - connectionName: coordinator.displayName, - queryPreview: preview - ) - let initialState = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: nil, - rowsStreamed: 0 - ) - // 5-minute stale window: if the app crashes mid-query, iOS marks the - // activity stale instead of showing a forever-ticking timer. - return try? Activity.request( - attributes: attributes, - content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(5 * 60)) + private func recordHistory(query: String, outcome: QueryExecutionOutcome, errorMessage: String?) { + coordinator.addHistoryItem( + QueryHistoryItem( + query: query, + connectionId: connectionId, + wasSuccessful: outcome == .completed, + errorMessage: errorMessage + ) ) } - /// Polls the streaming row count once per second while the query runs and pushes - /// `activity.update(state:)` only when the count changes. The system rate-limits - /// activity updates anyway, and the lock screen card just needs a fresh number - /// when the user wakes the device mid-query - it does not need real-time ticks - /// for the count (the elapsed time ticks itself via `Text(timerInterval:)`). - private func startActivityProgressUpdater( - activity: Activity?, - startedAt: Date - ) -> Task { + // MARK: - Live Activity + + private func startActivityProgressUpdater(token: QueryExecutionToken?) -> Task { + let controller = appState.queryActivities return Task { [weak viewModel] in - guard let activity else { return } - nonisolated(unsafe) let liveActivity = activity - var lastReportedCount = 0 while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) if Task.isCancelled { return } - let count = viewModel?.legacyRows.count ?? 0 - guard count != lastReportedCount else { continue } - lastReportedCount = count - let state = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: nil, - rowsStreamed: count - ) - await liveActivity.update(.init( - state: state, - staleDate: startedAt.addingTimeInterval(5 * 60) - )) + guard let count = viewModel?.legacyRows.count else { return } + await controller.update(token: token, rowsStreamed: count) } } } - - private func endQueryActivity(_ activity: Activity?, startedAt: Date) { - guard let activity else { return } - let final = QueryActivityAttributes.ContentState( - startedAt: startedAt, - endedAt: Date(), - rowsStreamed: viewModel.legacyRows.count - ) - nonisolated(unsafe) let liveActivity = activity - Task { - await liveActivity.end(.init(state: final, staleDate: nil), dismissalPolicy: .immediate) - } - } } diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 820c71f004..54921a5ce5 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -33,6 +33,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { } var beforeDisconnect: (@Sendable () async -> Void)? + var beforeExecute: (@Sendable () async -> Void)? func connect() async throws {} func disconnect() async throws { @@ -42,6 +43,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func cancelCurrentQuery() async throws {} func execute(query: String) async throws -> QueryResult { + await beforeExecute?() executedQueries.append(query) guard !scriptedExecuteResults.isEmpty else { return QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0) diff --git a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift new file mode 100644 index 0000000000..f3b8f65a8f --- /dev/null +++ b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift @@ -0,0 +1,477 @@ +import Foundation +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +private final class SpyLiveActivityHandle: LiveActivityHandle { + let id: String + private(set) var state: QueryActivityAttributes.ContentState + private(set) var endedStates: [QueryActivityAttributes.ContentState] = [] + private(set) var updatedStaleDates: [Date?] = [] + weak var store: SpyLiveActivityStore? + + init(id: String, state: QueryActivityAttributes.ContentState) { + self.id = id + self.state = state + } + + func update(state: QueryActivityAttributes.ContentState, staleDate: Date?) async { + self.state = state + updatedStaleDates.append(staleDate) + } + + func end(state: QueryActivityAttributes.ContentState) async { + self.state = state + endedStates.append(state) + store?.forget(self) + } +} + +@MainActor +private final class SpyLiveActivityStore: LiveActivityStore { + var areActivitiesEnabled = true + var existing: [SpyLiveActivityHandle] = [] + var requested: [SpyLiveActivityHandle] = [] + var requestError: (any Error)? + private var nextId = 0 + + var liveActivities: [any LiveActivityHandle] { existing } + + func adopt(_ handles: [SpyLiveActivityHandle]) { + for handle in handles { + handle.store = self + } + existing = handles + } + + func forget(_ handle: SpyLiveActivityHandle) { + existing.removeAll { $0 === handle } + } + + func request( + attributes: QueryActivityAttributes, + state: QueryActivityAttributes.ContentState, + staleDate: Date? + ) throws -> any LiveActivityHandle { + if let requestError { + throw requestError + } + nextId += 1 + let handle = SpyLiveActivityHandle(id: "new-\(nextId)", state: state) + handle.store = self + requested.append(handle) + existing.append(handle) + return handle + } +} + +@MainActor +private final class SpyAsserter: BackgroundTaskAsserting { + private(set) var beginCount = 0 + private(set) var endCount = 0 + + func beginBackgroundTask(name: String, expirationHandler: @escaping () -> Void) -> UIBackgroundTaskIdentifier { + beginCount += 1 + return UIBackgroundTaskIdentifier(rawValue: 11) + } + + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + endCount += 1 + } +} + +private let referenceNow = Date(timeIntervalSince1970: 1_700_000_000) + +@MainActor +private func makeController( + store: SpyLiveActivityStore, + asserter: SpyAsserter = SpyAsserter() +) -> QueryActivityController { + QueryActivityController(store: store, asserter: asserter, now: { referenceNow }) +} + +@MainActor +@Suite(.serialized) +struct QueryActivityControllerTests { + @Test + func reapEndsEveryActivityLeftByAPreviousProcess() async { + let store = SpyLiveActivityStore() + let orphan = SpyLiveActivityHandle( + id: "orphan", + state: .init(startedAt: referenceNow.addingTimeInterval(-90)) + ) + store.adopt([orphan]) + let controller = makeController(store: store) + + await controller.reapOrphans() + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select * FROM User", + startedAt: referenceNow + ) + + #expect(orphan.endedStates.count == 1) + #expect(orphan.endedStates.first?.outcome == .interrupted) + #expect(orphan.endedStates.first?.endedAt == referenceNow) + } + + @Test + func reapPreservesTheElapsedTimeTheOrphanRecorded() async { + let store = SpyLiveActivityStore() + let startedAt = referenceNow.addingTimeInterval(-136) + let orphan = SpyLiveActivityHandle( + id: "orphan", + state: .init(startedAt: startedAt, rowsStreamed: 42) + ) + store.adopt([orphan]) + let controller = makeController(store: store) + + await controller.reapOrphans() + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + let final = orphan.endedStates.first + #expect(final?.startedAt == startedAt) + #expect(final?.rowsStreamed == 42) + } + + @Test + func reapNeverEndsAnActivityThisProcessOwns() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + _ = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let owned = store.requested.first + await controller.reapOrphans() + + #expect(owned?.endedStates.isEmpty == true) + #expect(controller.ownedActivityIds.count == 1) + } + + @Test + func aSecondConnectionsActivitySurvivesAReapWhileBothRun() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "First", + query: "select 1", + startedAt: referenceNow + ) + _ = await controller.start( + connectionId: UUID(), + connectionName: "Second", + query: "select 2", + startedAt: referenceNow + ) + await controller.reapOrphans() + + #expect(store.requested.count == 2) + #expect(store.requested.allSatisfy { $0.endedStates.isEmpty }) + } + + @Test + func twoScenesOnOneConnectionKeepSeparateActivities() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let shared = UUID() + + let first = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let second = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + + #expect(first != second) + #expect(store.requested.count == 2) + #expect(store.requested.allSatisfy { $0.endedStates.isEmpty }) + + await controller.end(token: second, outcome: .completed) + + #expect(store.requested.first?.endedStates.isEmpty == true) + #expect(controller.ownedActivityIds.count == 1) + } + + @Test + func endingOneSceneLeavesTheOtherSceneUpdatable() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let shared = UUID() + + let first = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + let second = await controller.start( + connectionId: shared, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + await controller.end(token: second, outcome: .completed) + await controller.update(token: first, rowsStreamed: 5) + + #expect(store.requested.first?.state.rowsStreamed == 5) + } + + @Test + func suspensionEndsEveryActivityOnThatConnectionOnly() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let doomed = UUID() + let survivor = UUID() + + _ = await controller.start( + connectionId: doomed, + connectionName: "DuckDB", + query: "select 1", + startedAt: referenceNow + ) + _ = await controller.start( + connectionId: survivor, + connectionName: "SIT", + query: "select 2", + startedAt: referenceNow + ) + + await controller.endEverything(forConnection: doomed, outcome: .interrupted) + + #expect(store.requested[0].endedStates.first?.outcome == .interrupted) + #expect(store.requested[1].endedStates.isEmpty) + } + + @Test + func aLongQueryWithNoNewRowsStillRefreshesItsStaleDate() async { + let store = SpyLiveActivityStore() + var clock = referenceNow + let controller = QueryActivityController( + store: store, + asserter: SpyAsserter(), + now: { clock } + ) + let token = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + clock = referenceNow.addingTimeInterval(QueryActivityStaleWindow.seconds / 2 + 1) + await controller.update(token: token, rowsStreamed: 0) + + let handle = store.requested.first + #expect(handle?.updatedStaleDates.count == 1) + #expect(handle?.state.lastUpdatedAt == clock) + } + + @Test + func endRecordsTheOutcomeTheQueryActuallyReached() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.end(token: token, outcome: .stopped) + + let final = store.requested.first?.endedStates.first + #expect(final?.outcome == .stopped) + #expect(final?.endedAt == referenceNow) + } + + @Test + func endHoldsABackgroundAssertionForTheWholeCall() async { + let store = SpyLiveActivityStore() + let asserter = SpyAsserter() + let controller = makeController(store: store, asserter: asserter) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.end(token: token, outcome: .completed) + + #expect(asserter.beginCount >= 1) + #expect(asserter.endCount == asserter.beginCount) + } + + @Test + func theStaleDateSlidesForwardWithEveryProgressUpdate() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + let startedAt = referenceNow.addingTimeInterval(-240) + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: startedAt + ) + await controller.update(token: token, rowsStreamed: 500) + + let slid = store.requested.first?.updatedStaleDates.first ?? nil + #expect(slid == referenceNow.addingTimeInterval(QueryActivityStaleWindow.seconds)) + } + + @Test + func aRepeatedRowCountSendsNoUpdate() async { + let store = SpyLiveActivityStore() + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + await controller.update(token: token, rowsStreamed: 12) + await controller.update(token: token, rowsStreamed: 12) + + #expect(store.requested.first?.updatedStaleDates.count == 1) + } + + @Test + func aFailedRequestLeavesNothingOwnedSoTheNextReapIsUnaffected() async { + let store = SpyLiveActivityStore() + store.requestError = CocoaError(.fileNoSuchFile) + let controller = makeController(store: store) + let connectionId = UUID() + + let token = await controller.start( + connectionId: connectionId, + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(token == nil) + #expect(controller.ownedActivityIds.isEmpty) + } + + @Test + func nothingIsRequestedWhileLiveActivitiesAreTurnedOff() async { + let store = SpyLiveActivityStore() + store.areActivitiesEnabled = false + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(store.requested.isEmpty) + } + + @Test + func aDisabledStoreStillReapsWhatAnEarlierProcessLeftBehind() async { + let store = SpyLiveActivityStore() + store.areActivitiesEnabled = false + let orphan = SpyLiveActivityHandle(id: "orphan", state: .init(startedAt: referenceNow)) + store.adopt([orphan]) + let controller = makeController(store: store) + + _ = await controller.start( + connectionId: UUID(), + connectionName: "SIT", + query: "select 1", + startedAt: referenceNow + ) + + #expect(orphan.endedStates.count == 1) + } +} + +@Suite +struct QueryActivityContentStateDecodingTests { + @Test + func aStateEncodedBeforeTheOutcomeFieldExistedStillDecodes() throws { + let legacy = #"{"startedAt": 757400000, "rowsStreamed": 7}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.outcome == .running) + #expect(state.rowsStreamed == 7) + #expect(state.endedAt == nil) + } + + @Test + func aLegacyEndedStateDecodesAsCompletedRatherThanRunning() throws { + let legacy = #"{"startedAt": 757400000, "endedAt": 757400012, "rowsStreamed": 3}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.outcome == .completed) + } + + @Test + func aStaleCardReportsTheElapsedTimeItLastReachedNotTheStaleWindow() { + let startedAt = referenceNow.addingTimeInterval(-900) + let state = QueryActivityAttributes.ContentState( + startedAt: startedAt, + lastUpdatedAt: referenceNow.addingTimeInterval(-300) + ) + + #expect(state.elapsedWhenLastAlive == 600) + } + + @Test + func aLegacyStateReportsNoElapsedTimeRatherThanANegativeOne() throws { + let legacy = #"{"startedAt": 757400000, "rowsStreamed": 2}"# + let data = try #require(legacy.data(using: .utf8)) + + let state = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(state.lastUpdatedAt == state.startedAt) + #expect(state.elapsedWhenLastAlive == 0) + } + + @Test + func anOutcomeSurvivesARoundTrip() throws { + let original = QueryActivityAttributes.ContentState( + startedAt: referenceNow, + endedAt: referenceNow.addingTimeInterval(3), + rowsStreamed: 9, + outcome: .stopped + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(QueryActivityAttributes.ContentState.self, from: data) + + #expect(decoded == original) + } +} diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index 99a3a98979..a5876e7288 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -35,6 +35,45 @@ struct QueryEditorViewModelTests { } } + @Test("stop marks the run cancelled rather than letting it settle as finished") + func stopRecordsCancellation() async { + let driver = MockDatabaseDriver() + let gate = QueryGate() + driver.beforeExecute = { await gate.wait() } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + let run = Task { await vm.run(driver: driver, query: "SELECT 1") } + while !vm.isRunning { + await Task.yield() + } + vm.stop() + await gate.open() + await run.value + + #expect(vm.truncationReason != nil) + if case .truncated(let reason) = vm.phase, case .cancelled = reason { + #expect(vm.truncationMessage != nil) + } else { + Issue.record("expected truncated(.cancelled) phase, got \(vm.phase)") + } + #expect(QueryExecutionOutcome(phase: vm.phase) == .stopped) + } + + @Test("stop on an idle view model changes nothing") + func stopWhenIdleIsInert() { + let vm = QueryEditorViewModel(windowCapacity: 100) + + vm.stop() + + #expect(vm.truncationReason == nil) + if case .idle = vm.phase {} else { + Issue.record("expected idle phase, got \(vm.phase)") + } + } + @Test("run completes without truncation for a small result") func runCompletes() async { let driver = MockDatabaseDriver() @@ -94,3 +133,19 @@ struct QueryEditorViewModelTests { } } } + +private actor QueryGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation = $0 } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} diff --git a/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift b/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift new file mode 100644 index 0000000000..92d1c26623 --- /dev/null +++ b/TableProMobile/TableProMobileTests/QueryExecutionOutcomeTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite +struct QueryExecutionOutcomeTests { + @Test + func aFinishedQueryIsTheOnlyPlainSuccess() { + #expect(QueryExecutionOutcome(phase: .finished) == .completed) + } + + @Test + func aStoppedQueryIsNotASuccess() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .cancelled)) == .stopped) + } + + @Test + func aMemoryTruncatedQueryIsNotASuccess() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .memoryPressure)) == .interrupted) + } + + @Test + func hittingTheRowCapStillCountsAsCompleted() { + #expect(QueryExecutionOutcome(phase: .truncated(reason: .rowCap(10_000))) == .completed) + #expect(QueryExecutionOutcome(phase: .truncated(reason: .driverLimit("server limit"))) == .completed) + } + + @Test + func clearingMidRunLeavesTheRunInterrupted() { + #expect(QueryExecutionOutcome(phase: .idle) == .interrupted) + } + + @Test + func onlyACompletedRunCarriesNoHistoryMessage() { + #expect(QueryExecutionOutcome.completed.historyMessage == nil) + #expect(QueryExecutionOutcome.failed.historyMessage == nil) + #expect(QueryExecutionOutcome.stopped.historyMessage != nil) + #expect(QueryExecutionOutcome.interrupted.historyMessage != nil) + } + + @Test + func everyOutcomeMapsOntoADistinctActivityOutcome() { + let mapped: [QueryActivityAttributes.Outcome] = [ + QueryExecutionOutcome.completed.activityOutcome, + QueryExecutionOutcome.failed.activityOutcome, + QueryExecutionOutcome.stopped.activityOutcome, + QueryExecutionOutcome.interrupted.activityOutcome, + ] + + #expect(Set(mapped).count == mapped.count) + #expect(!mapped.contains(.running)) + } +} diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 43d4009e8e..b704ca1181 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -17,9 +17,9 @@ struct QueryLiveActivityWidget: Widget { .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7)) } DynamicIslandExpandedRegion(.trailing) { - elapsedText(context.state) + elapsedText(context.state, isStale: context.isStale) .font(.title3.monospacedDigit()) - .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary) + .foregroundStyle(isLive(context) ? .primary : .secondary) } DynamicIslandExpandedRegion(.center) { Text(context.attributes.connectionName) @@ -34,21 +34,18 @@ struct QueryLiveActivityWidget: Widget { .lineLimit(1) .truncationMode(.tail) Spacer() - if context.state.rowsStreamed > 0 { - Label(rowCountText(context.state.rowsStreamed), systemImage: "list.bullet") - .font(.caption) - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } + Text(statusText(context)) + .font(.caption) + .foregroundStyle(.secondary) } } } compactLeading: { Image(systemName: "terminal.fill") .foregroundStyle(.tint) } compactTrailing: { - compactStatus(state: context.state) + compactStatus(context) } minimal: { - compactStatus(state: context.state) + compactStatus(context) } .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } @@ -78,13 +75,11 @@ struct QueryLiveActivityWidget: Widget { Spacer() VStack(alignment: .trailing, spacing: 2) { - elapsedText(context.state) + elapsedText(context.state, isStale: context.isStale) .font(.body.monospacedDigit()) - if context.state.rowsStreamed > 0 { - Text(rowCountText(context.state.rowsStreamed)) - .font(.caption2) - .foregroundStyle(.secondary) - } + Text(statusText(context)) + .font(.caption2) + .foregroundStyle(.secondary) } } .padding(.horizontal, 14) @@ -94,23 +89,76 @@ struct QueryLiveActivityWidget: Widget { // MARK: - Compact / Minimal Status @ViewBuilder - private func compactStatus(state: QueryActivityAttributes.ContentState) -> some View { - if state.endedAt != nil { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { + private func compactStatus(_ context: ActivityViewContext) -> some View { + if isLive(context) { ProgressView() .progressViewStyle(.circular) .controlSize(.mini) + } else { + Image(systemName: symbolName(for: outcome(context))) + .foregroundStyle(tint(for: outcome(context))) } } // MARK: - Helpers + private func isLive(_ context: ActivityViewContext) -> Bool { + context.state.endedAt == nil && !context.isStale + } + + private func outcome(_ context: ActivityViewContext) -> QueryActivityAttributes.Outcome { + guard context.state.outcome == .running else { return context.state.outcome } + return context.isStale ? .interrupted : .running + } + + private func symbolName(for outcome: QueryActivityAttributes.Outcome) -> String { + switch outcome { + case .running: "hourglass" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .stopped: "stop.circle.fill" + case .interrupted: "exclamationmark.triangle.fill" + } + } + + private func tint(for outcome: QueryActivityAttributes.Outcome) -> Color { + switch outcome { + case .running: .secondary + case .completed: .green + case .failed: .red + case .stopped: .secondary + case .interrupted: .orange + } + } + + private func statusText(_ context: ActivityViewContext) -> String { + switch outcome(context) { + case .running: + return context.state.rowsStreamed > 0 + ? rowCountText(context.state.rowsStreamed) + : String(localized: "Running") + case .completed: + return context.state.rowsStreamed > 0 + ? rowCountText(context.state.rowsStreamed) + : String(localized: "Done") + case .failed: + return String(localized: "Failed") + case .stopped: + return String(localized: "Stopped") + case .interrupted: + return String(localized: "Interrupted") + } + } + @ViewBuilder - private func elapsedText(_ state: QueryActivityAttributes.ContentState) -> some View { + private func elapsedText( + _ state: QueryActivityAttributes.ContentState, + isStale: Bool + ) -> some View { if let ended = state.endedAt { Text(formatElapsed(ended.timeIntervalSince(state.startedAt))) + } else if isStale { + Text(formatElapsed(state.elapsedWhenLastAlive)) } else { Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false, showsHours: false) } diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift index d6e8c7d21d..0f6da58893 100644 --- a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift +++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift @@ -1,11 +1,63 @@ import ActivityKit import Foundation +enum QueryActivityStaleWindow { + static let seconds: TimeInterval = 5 * 60 +} + nonisolated struct QueryActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { + enum Outcome: String, Codable, Hashable, Sendable { + case running + case completed + case failed + case stopped + case interrupted + } + + struct ContentState: Codable, Hashable { var startedAt: Date + var lastUpdatedAt: Date var endedAt: Date? var rowsStreamed: Int + var outcome: Outcome + + init( + startedAt: Date, + lastUpdatedAt: Date? = nil, + endedAt: Date? = nil, + rowsStreamed: Int = 0, + outcome: Outcome = .running + ) { + self.startedAt = startedAt + self.lastUpdatedAt = lastUpdatedAt ?? startedAt + self.endedAt = endedAt + self.rowsStreamed = rowsStreamed + self.outcome = outcome + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + startedAt = try container.decode(Date.self, forKey: .startedAt) + lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt) ?? startedAt + endedAt = try container.decodeIfPresent(Date.self, forKey: .endedAt) + rowsStreamed = try container.decodeIfPresent(Int.self, forKey: .rowsStreamed) ?? 0 + let stored = try container.decodeIfPresent(Outcome.self, forKey: .outcome) + outcome = stored ?? (endedAt == nil ? .running : .completed) + } + + var elapsedWhenLastAlive: TimeInterval { + max(0, lastUpdatedAt.timeIntervalSince(startedAt)) + } + + func ended(as outcome: Outcome, at endedAt: Date) -> ContentState { + ContentState( + startedAt: startedAt, + lastUpdatedAt: endedAt, + endedAt: endedAt, + rowsStreamed: rowsStreamed, + outcome: outcome + ) + } } let connectionId: UUID diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index b0ace05e7c..fe5c585d80 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -64,7 +64,9 @@ A new row starts with every column on **DEFAULT**, which leaves that column out ### Querying -The editor highlights SQL, runs a statement, and stops one mid-flight. Results copy or export as JSON, CSV, or SQL `INSERT`. A running query appears in a Live Activity on the lock screen and Dynamic Island; **Settings > Privacy** hides the SQL text there. +The editor highlights SQL, runs a statement, and stops one mid-flight. **Stop** ends the result stream at once; on MySQL and Redis the statement keeps running on the server until it finishes. Results copy or export as JSON, CSV, or SQL `INSERT`. + +A running query appears in a Live Activity on the lock screen and Dynamic Island, with the elapsed time and the row count so far. **Settings > Privacy** replaces the SQL text there with "Running query". The card clears when the query ends. Quitting the app mid-query leaves it behind, marked interrupted, until the next launch clears it. ## What is missing