From a1ccb748ef381c2e4ca435f63b7d37a9503a0fc1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 16:53:39 +0700 Subject: [PATCH] fix(ios): mark a memory-stopped query before cancelling it and hold the activity through its end Claude-Session: https://claude.ai/code/session_01DeXDeLTqEXdRgsPkvPkdPP --- CHANGELOG.md | 2 +- .../Platform/QueryActivityController.swift | 14 ++++--- .../ViewModels/QueryEditorViewModel.swift | 3 +- .../Views/QueryEditorView.swift | 27 +++++------- .../Views/QueryHistoryView.swift | 2 +- .../QueryActivityControllerTests.swift | 42 +++++++++++++++++++ .../QueryEditorViewModelTests.swift | 25 +++++++++++ 7 files changed, 89 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 165b2bb94e..e6284100bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TableProMobile/TableProMobile/Platform/QueryActivityController.swift b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift index 9e798dc136..efc53ba75f 100644 --- a/TableProMobile/TableProMobile/Platform/QueryActivityController.swift +++ b/TableProMobile/TableProMobile/Platform/QueryActivityController.swift @@ -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 @@ -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 @@ -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) } } diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index 42e93ca3f9..b9c75557f7 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -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) } } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 6688a72c3e..74dc3f738f 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -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 } @@ -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?) { diff --git a/TableProMobile/TableProMobile/Views/QueryHistoryView.swift b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift index ba6f87586b..cf189a40a3 100644 --- a/TableProMobile/TableProMobile/Views/QueryHistoryView.swift +++ b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift @@ -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)) diff --git a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift index f3b8f65a8f..1b3fe3b85c 100644 --- a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift +++ b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift @@ -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? init(id: String, state: QueryActivityAttributes.ContentState) { self.id = id @@ -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 @@ -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() diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index a5876e7288..e89e13ba27 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -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)