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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- 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.
- A stopped or memory-stopped iOS query recorded in Query History as successful, including a write that streamed no rows.
- 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class QueryActivityController {
let connectionId: UUID
let handle: any LiveActivityHandle
var lastUpdatedAt: Date
var isEnding = false
}

private let store: any LiveActivityStore
Expand Down Expand Up @@ -106,7 +107,7 @@ final class QueryActivityController {
}

func update(token: QueryExecutionToken?, rowsStreamed: Int) async {
guard let token, let execution = executions[token] else { return }
guard let token, let execution = executions[token], !execution.isEnding else { return }
let instant = now()
let rowsChanged = execution.handle.state.rowsStreamed != rowsStreamed
let heartbeatDue = instant.timeIntervalSince(execution.lastUpdatedAt) >= Self.heartbeatInterval
Expand All @@ -123,18 +124,21 @@ final class QueryActivityController {
}

func end(token: QueryExecutionToken?, outcome: QueryActivityAttributes.Outcome) async {
guard let token, let execution = executions.removeValue(forKey: token) else { return }
guard let token, let execution = executions[token], !execution.isEnding else { return }
executions[token]?.isEnding = true
await end(execution: execution, outcome: outcome)
executions.removeValue(forKey: token)
}

func endEverything(forConnection connectionId: UUID, outcome: QueryActivityAttributes.Outcome) async {
let matching = executions.filter { $0.value.connectionId == connectionId }
let matching = executions.filter { $0.value.connectionId == connectionId && !$0.value.isEnding }
guard !matching.isEmpty else { return }
for token in matching.keys {
executions.removeValue(forKey: token)
executions[token]?.isEnding = true
}
for execution in matching.values {
for (token, execution) in matching {
await end(execution: execution, outcome: outcome)
executions.removeValue(forKey: token)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,8 @@ final class QueryEditorViewModel {
case .warning, .critical:
guard case .running = self.phase else { return }
Self.logger.warning("Memory pressure: stopping query stream to stay within limits")
self.fetchTask?.cancel()
guard !self.buffer.isEmpty else { return }
self.buffer.markTruncated(.memoryPressure)
self.fetchTask?.cancel()
self.phase = .truncated(reason: .memoryPressure)
}
}
Expand Down
27 changes: 10 additions & 17 deletions TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,10 +434,10 @@ struct QueryEditorView: View {
)

guard !Task.isCancelled else {
recordHistory(query: trimmed, outcome: .stopped, errorMessage: QueryExecutionOutcome.stopped.historyMessage)
isExecuting = false
executionStartTime = nil
await appState.queryActivities.end(token: token, outcome: .stopped)
recordHistory(query: trimmed, outcome: .stopped, errorMessage: QueryExecutionOutcome.stopped.historyMessage)
return
}

Expand All @@ -448,31 +448,24 @@ struct QueryEditorView: View {
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()
recordHistory(query: trimmed, outcome: outcome, errorMessage: err.localizedDescription)
return
}

executionTime = elapsed

guard outcome == .completed else {
} else {
executionTime = viewModel.executionTime
if outcome == .completed {
hapticSuccess.toggle()
IOSAnalyticsProvider.shared.markFirstQueryExecuted()
}
recordHistory(query: trimmed, outcome: outcome, errorMessage: outcome.historyMessage)
return
}

hapticSuccess.toggle()

IOSAnalyticsProvider.shared.markFirstQueryExecuted()
isExecuting = false
executionStartTime = nil

recordHistory(query: trimmed, outcome: outcome, errorMessage: nil)
await appState.queryActivities.end(token: token, outcome: outcome.activityOutcome)
}

private func recordHistory(query: String, outcome: QueryExecutionOutcome, errorMessage: String?) {
Expand Down
2 changes: 1 addition & 1 deletion TableProMobile/TableProMobile/Views/QueryHistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct QueryHistoryView: View {
Image(systemName: "exclamationmark.circle.fill")
.font(.footnote)
.foregroundStyle(.red)
.accessibilityLabel(String(localized: "Failed"))
.accessibilityLabel(item.errorMessage ?? String(localized: "Failed"))
}
Text(verbatim: item.query)
.font(.system(.footnote, design: .monospaced))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ private final class SpyLiveActivityHandle: LiveActivityHandle {
private(set) var endedStates: [QueryActivityAttributes.ContentState] = []
private(set) var updatedStaleDates: [Date?] = []
weak var store: SpyLiveActivityStore?
var holdsEndUntilReleased = false
private(set) var isEndParked = false
private var endGate: CheckedContinuation<Void, Never>?

init(id: String, state: QueryActivityAttributes.ContentState) {
self.id = id
Expand All @@ -22,10 +25,22 @@ private final class SpyLiveActivityHandle: LiveActivityHandle {
}

func end(state: QueryActivityAttributes.ContentState) async {
if holdsEndUntilReleased {
await withCheckedContinuation {
endGate = $0
isEndParked = true
}
}
self.state = state
endedStates.append(state)
store?.forget(self)
}

func releaseEnd() {
isEndParked = false
endGate?.resume()
endGate = nil
}
}

@MainActor
Expand Down Expand Up @@ -305,6 +320,33 @@ struct QueryActivityControllerTests {
#expect(final?.endedAt == referenceNow)
}

@Test
func aReapDuringAnInFlightEndDoesNotEndTheActivityTwice() async {
let store = SpyLiveActivityStore()
let controller = makeController(store: store)

let token = await controller.start(
connectionId: UUID(),
connectionName: "SIT",
query: "select 1",
startedAt: referenceNow
)
let handle = store.requested.first
handle?.holdsEndUntilReleased = true

let ending = Task { await controller.end(token: token, outcome: .completed) }
while handle?.isEndParked == false {
await Task.yield()
}
await controller.reapOrphans()
handle?.releaseEnd()
await ending.value

#expect(handle?.endedStates.count == 1)
#expect(handle?.endedStates.first?.outcome == .completed)
#expect(controller.ownedActivityIds.isEmpty)
}

@Test
func endHoldsABackgroundAssertionForTheWholeCall() async {
let store = SpyLiveActivityStore()
Expand Down
25 changes: 25 additions & 0 deletions TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ struct QueryEditorViewModelTests {
#expect(QueryExecutionOutcome(phase: vm.phase) == .stopped)
}

@Test("memory pressure before the first row still marks the run interrupted")
func pressureBeforeAnyRowIsNotASuccess() async {
let driver = MockDatabaseDriver()
let gate = QueryGate()
driver.beforeExecute = { await gate.wait() }
driver.scriptedExecuteResults = [
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 1, executionTime: 0))
]

let vm = QueryEditorViewModel(windowCapacity: 100)
let run = Task { await vm.run(driver: driver, query: "UPDATE t SET a = 1") }
while !vm.isRunning {
await Task.yield()
}
await vm.handlePressure(.critical)
await gate.open()
await run.value

if case .truncated(let reason) = vm.phase, case .memoryPressure = reason {
#expect(QueryExecutionOutcome(phase: vm.phase) == .interrupted)
} else {
Issue.record("expected truncated(.memoryPressure) phase, got \(vm.phase)")
}
}

@Test("stop on an idle view model changes nothing")
func stopWhenIdleIsInert() {
let vm = QueryEditorViewModel(windowCapacity: 100)
Expand Down
Loading