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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions TableProMobile/TableProMobile/Models/QueryExecutionOutcome.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

extension UIApplication: BackgroundTaskAsserting {
func beginBackgroundTask(name: String, expirationHandler: @escaping () -> Void) -> UIBackgroundTaskIdentifier {
beginBackgroundTask(withName: name, expirationHandler: expirationHandler)

Check warning on line 13 in TableProMobile/TableProMobile/Platform/BackgroundReleaseCoordinator.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

passing non-Sendable parameter 'expirationHandler' to function expecting a '@sendable' closure
}
}

Expand Down Expand Up @@ -40,17 +40,19 @@
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 {
Expand Down
240 changes: 240 additions & 0 deletions TableProMobile/TableProMobile/Platform/QueryActivityController.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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<String> {
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<QueryActivityAttributes>.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<QueryActivityAttributes>

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)
}
}
8 changes: 7 additions & 1 deletion TableProMobile/TableProMobile/TableProMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import TableProModels
@MainActor
@Observable
final class QueryEditorViewModel {
enum Phase: Sendable {
nonisolated enum Phase: Sendable {
case idle
case running
case finished
Expand Down Expand Up @@ -105,6 +105,8 @@ final class QueryEditorViewModel {
}

func stop() {
guard case .running = phase else { return }
buffer.markTruncated(.cancelled)
fetchTask?.cancel()
}

Expand Down
Loading
Loading