From 4fa38f8d0e8da0597aa9acc935d07562956766c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 19:13:46 +0700 Subject: [PATCH 1/2] fix(ios): prevent crash when a query returns a very large result --- CHANGELOG.md | 1 + .../TableProMobile/Localizable.xcstrings | 12 ++ .../Models/StreamingResultBuffer.swift | 99 +++++++++++ .../ViewModels/DataBrowserViewModel.swift | 102 +++-------- .../ViewModels/QueryEditorViewModel.swift | 160 +++++++----------- .../Views/QueryEditorView.swift | 28 ++- .../QueryEditorViewModelTests.swift | 94 ++++++++++ .../StreamingResultBufferTests.swift | 53 ++++++ 8 files changed, 369 insertions(+), 180 deletions(-) create mode 100644 TableProMobile/TableProMobile/Models/StreamingResultBuffer.swift create mode 100644 TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift create mode 100644 TableProMobile/TableProMobileTests/StreamingResultBufferTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bf06ab8ac..ca2cb5847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483) - AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291) - iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app. +- iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more. ## [0.45.0] - 2026-05-26 diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 646f230c4..59c8f243d 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -3862,6 +3862,9 @@ } } } + }, + "Showing the first %d rows. Add LIMIT to fetch more." : { + }, "Skip" : { "localizations" : { @@ -4087,6 +4090,12 @@ } } } + }, + "Stopped at %d rows to stay within memory limits. Add LIMIT to fetch fewer." : { + + }, + "Stopped. Showing %d rows." : { + }, "Switching..." : { "extractionState" : "stale", @@ -4298,6 +4307,9 @@ } } } + }, + "The database limited the result. Showing %d rows." : { + }, "The operation violates a database constraint." : { "localizations" : { diff --git a/TableProMobile/TableProMobile/Models/StreamingResultBuffer.swift b/TableProMobile/TableProMobile/Models/StreamingResultBuffer.swift new file mode 100644 index 000000000..8a262c15c --- /dev/null +++ b/TableProMobile/TableProMobile/Models/StreamingResultBuffer.swift @@ -0,0 +1,99 @@ +import Foundation +import TableProModels + +@MainActor +@Observable +final class StreamingResultBuffer { + private(set) var columns: [ColumnInfo] = [] + private(set) var window: RowWindow + private(set) var legacyRows: [[String?]] = [] + private(set) var rowsAffected: Int? + private(set) var statusMessage: String? + private(set) var truncation: TruncationReason? + + @ObservationIgnored private var pendingRows: [Row] = [] + @ObservationIgnored private var flushTask: Task? + + private static let flushBatchSize = 200 + private static let flushInterval: Duration = .milliseconds(50) + + init(capacity: Int) { + self.window = RowWindow(capacity: capacity) + } + + var count: Int { legacyRows.count } + var isEmpty: Bool { legacyRows.isEmpty } + + func apply(_ element: StreamElement) { + switch element { + case .columns(let cols): + columns = cols + case .row(let row): + pendingRows.append(row) + scheduleFlush() + case .rowsAffected(let count): + flush() + rowsAffected = count + case .statusMessage(let message): + flush() + statusMessage = message + case .truncated(let reason): + flush() + truncation = reason + } + } + + func markTruncated(_ reason: TruncationReason) { + truncation = reason + } + + func flush() { + flushTask?.cancel() + flushTask = nil + guard !pendingRows.isEmpty else { return } + let legacyBatch = pendingRows.map(\.legacyValues) + window.append(contentsOf: pendingRows) + legacyRows.append(contentsOf: legacyBatch) + if legacyRows.count > window.count { + legacyRows.removeFirst(legacyRows.count - window.count) + } + pendingRows.removeAll(keepingCapacity: true) + } + + func cancelFlush() { + flushTask?.cancel() + flushTask = nil + } + + func shrink(to maxCount: Int) { + window.shrink(to: maxCount) + guard legacyRows.count > maxCount else { return } + legacyRows.removeFirst(legacyRows.count - maxCount) + } + + func reset() { + flushTask?.cancel() + flushTask = nil + columns = [] + window.clear() + legacyRows.removeAll(keepingCapacity: true) + rowsAffected = nil + statusMessage = nil + truncation = nil + pendingRows.removeAll(keepingCapacity: true) + } + + private func scheduleFlush() { + if pendingRows.count >= Self.flushBatchSize { + flush() + return + } + if flushTask == nil { + flushTask = Task { [weak self] in + try? await Task.sleep(for: Self.flushInterval) + guard !Task.isCancelled else { return } + self?.flush() + } + } + } +} diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 0c36a5220..c7ed870d4 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -17,15 +17,17 @@ final class DataBrowserViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "DataBrowserViewModel") - private(set) var columns: [ColumnInfo] = [] - private(set) var window: RowWindow - private(set) var legacyRows: [[String?]] = [] + private let buffer: StreamingResultBuffer private(set) var totalRows: Int? private(set) var phase: Phase = .idle - private(set) var rowsAffected: Int? - private(set) var statusMessage: String? private(set) var executionTime: TimeInterval = 0 + var columns: [ColumnInfo] { buffer.columns } + var window: RowWindow { buffer.window } + var legacyRows: [[String?]] { buffer.legacyRows } + var rowsAffected: Int? { buffer.rowsAffected } + var statusMessage: String? { buffer.statusMessage } + private(set) var columnDetails: [ColumnInfo] = [] private(set) var foreignKeys: [ForeignKeyInfo] = [] private(set) var pagination: PaginationState @@ -43,16 +45,11 @@ final class DataBrowserViewModel { @ObservationIgnored private var table: TableInfo? @ObservationIgnored private var databaseType: DatabaseType = .mysql @ObservationIgnored private var host: String = "" - @ObservationIgnored private var pendingRows: [Row] = [] - @ObservationIgnored private var flushTask: Task? @ObservationIgnored private var fetchTask: Task? @ObservationIgnored private var searchTask: Task? - private static let flushBatchSize = 200 - private static let flushInterval: Duration = .milliseconds(50) - init(windowCapacity: Int = 1_000) { - self.window = RowWindow(capacity: windowCapacity) + self.buffer = StreamingResultBuffer(capacity: windowCapacity) self.pagination = PaginationState(pageSize: AppPreferences.defaultPageSize, currentPage: 0) } @@ -377,15 +374,13 @@ final class DataBrowserViewModel { break case .warning: Self.logger.warning("Memory pressure warning: shrinking window to 100 rows") - self.window.shrink(to: 100) - self.shrinkLegacyRows(to: 100) - if !self.legacyRows.isEmpty { + self.buffer.shrink(to: 100) + if !self.buffer.isEmpty { self.memoryWarning = String(localized: "Results trimmed due to memory pressure.") } case .critical: Self.logger.error("Memory pressure critical: shrinking window to 50 rows and cancelling") - self.window.shrink(to: 50) - self.shrinkLegacyRows(to: 50) + self.buffer.shrink(to: 50) self.fetchTask?.cancel() self.memoryWarning = String(localized: "Results trimmed due to memory pressure.") } @@ -393,8 +388,8 @@ final class DataBrowserViewModel { } func handleSystemMemoryWarning() async { - guard !legacyRows.isEmpty else { return } - Self.logger.warning("System memory warning: shrinking window from \(self.legacyRows.count) rows") + guard !buffer.isEmpty else { return } + Self.logger.warning("System memory warning: shrinking window from \(self.buffer.count) rows") await handlePressure(.warning) } @@ -418,12 +413,7 @@ final class DataBrowserViewModel { lazyContext: lazyContext ) phase = .loading - columns = [] - window.clear() - legacyRows.removeAll(keepingCapacity: true) - rowsAffected = nil - statusMessage = nil - pendingRows.removeAll(keepingCapacity: true) + buffer.reset() let start = Date() let task = Task { [weak self] in @@ -431,15 +421,17 @@ final class DataBrowserViewModel { do { for try await element in driver.executeStreaming(query: query, options: options) { if Task.isCancelled { break } - self.apply(element: element) + self.buffer.apply(element) } - self.flushPendingRows() + self.buffer.flush() self.executionTime = Date().timeIntervalSince(start) - if case .loading = self.phase { + if let reason = self.buffer.truncation { + self.phase = .truncated(reason: reason) + } else if case .loading = self.phase { self.phase = .loaded } } catch { - self.flushPendingRows() + self.buffer.flush() self.phase = .error(self.classify(error: error)) } } @@ -449,63 +441,11 @@ final class DataBrowserViewModel { func cancel() { fetchTask?.cancel() - flushTask?.cancel() + buffer.cancelFlush() searchTask?.cancel() - flushTask = nil searchTask = nil } - private func apply(element: StreamElement) { - switch element { - case .columns(let cols): - columns = cols - case .row(let row): - pendingRows.append(row) - scheduleFlushIfNeeded() - case .rowsAffected(let count): - flushPendingRows() - rowsAffected = count - case .statusMessage(let message): - flushPendingRows() - statusMessage = message - case .truncated(let reason): - flushPendingRows() - phase = .truncated(reason: reason) - } - } - - private func scheduleFlushIfNeeded() { - if pendingRows.count >= Self.flushBatchSize { - flushPendingRows() - return - } - if flushTask == nil { - flushTask = Task { [weak self] in - try? await Task.sleep(for: Self.flushInterval) - guard !Task.isCancelled else { return } - self?.flushPendingRows() - } - } - } - - private func flushPendingRows() { - flushTask?.cancel() - flushTask = nil - guard !pendingRows.isEmpty else { return } - let legacyBatch = pendingRows.map(\.legacyValues) - window.append(contentsOf: pendingRows) - legacyRows.append(contentsOf: legacyBatch) - if legacyRows.count > window.count { - legacyRows.removeFirst(legacyRows.count - window.count) - } - pendingRows.removeAll(keepingCapacity: true) - } - - private func shrinkLegacyRows(to count: Int) { - guard legacyRows.count > count else { return } - legacyRows.removeFirst(legacyRows.count - count) - } - private func classify(error: Error) -> AppError { let context = ErrorContext(operation: "loadPage", databaseType: databaseType) return ErrorClassifier.classify(error, context: context) diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index 1d5936b0f..c79a5a44d 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -15,35 +15,49 @@ final class QueryEditorViewModel { } private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorViewModel") + static let maxBufferedRows = 10_000 + private static let memorySafetyMarginBytes = 64 * 1_024 * 1_024 + private static let budgetCheckInterval = 500 - private(set) var columns: [ColumnInfo] = [] - private(set) var window: RowWindow - private(set) var legacyRows: [[String?]] = [] - private(set) var rowsReceived: Int = 0 + private let buffer: StreamingResultBuffer private(set) var phase: Phase = .idle - private(set) var rowsAffected: Int? - private(set) var statusMessage: String? private(set) var executionTime: TimeInterval = 0 - @ObservationIgnored private var pendingRows: [Row] = [] - @ObservationIgnored private var pendingRowsReceived: Int = 0 - @ObservationIgnored private var flushTask: Task? @ObservationIgnored private var fetchTask: Task? @ObservationIgnored private var startedAt: Date? - private static let flushBatchSize = 200 - private static let flushInterval: Duration = .milliseconds(50) - - init(windowCapacity: Int = 100_000) { - self.window = RowWindow(capacity: windowCapacity) + init(windowCapacity: Int = QueryEditorViewModel.maxBufferedRows) { + self.buffer = StreamingResultBuffer(capacity: windowCapacity) } + var columns: [ColumnInfo] { buffer.columns } + var window: RowWindow { buffer.window } + var legacyRows: [[String?]] { buffer.legacyRows } + var rowsAffected: Int? { buffer.rowsAffected } + var statusMessage: String? { buffer.statusMessage } + var truncationReason: TruncationReason? { buffer.truncation } + var isRunning: Bool { if case .running = phase { return true } return false } - func run(driver: DatabaseDriver, query: String, maxRows: Int = 100_000) async { + var truncationMessage: String? { + guard let truncationReason else { return nil } + let shown = buffer.legacyRows.count + switch truncationReason { + case .rowCap: + return String(format: String(localized: "Showing the first %d rows. Add LIMIT to fetch more."), shown) + case .memoryPressure: + return String(format: String(localized: "Stopped at %d rows to stay within memory limits. Add LIMIT to fetch fewer."), shown) + case .cancelled: + return String(format: String(localized: "Stopped. Showing %d rows."), shown) + case .driverLimit: + return String(format: String(localized: "The database limited the result. Showing %d rows."), shown) + } + } + + func run(driver: DatabaseDriver, query: String, maxRows: Int = QueryEditorViewModel.maxBufferedRows) async { fetchTask?.cancel() let options = StreamOptions( textTruncationBytes: 4_096, @@ -52,35 +66,36 @@ final class QueryEditorViewModel { lazyContext: nil ) phase = .running - columns = [] - window.clear() - legacyRows.removeAll(keepingCapacity: true) - rowsReceived = 0 - rowsAffected = nil - statusMessage = nil + buffer.reset() executionTime = 0 - pendingRows.removeAll(keepingCapacity: true) - pendingRowsReceived = 0 startedAt = Date() let task = Task { [weak self] in guard let self else { return } do { + var sinceBudgetCheck = 0 for try await element in driver.executeStreaming(query: query, options: options) { if Task.isCancelled { break } - self.apply(element: element) + self.buffer.apply(element) + guard case .row = element else { continue } + sinceBudgetCheck += 1 + if sinceBudgetCheck >= Self.budgetCheckInterval { + sinceBudgetCheck = 0 + if self.isMemoryConstrained() { + self.buffer.markTruncated(.memoryPressure) + break + } + } } - self.flushPendingRows() + self.buffer.flush() self.finalizeTiming() - if case .running = self.phase { - self.phase = .finished - } + self.resolvePhase() } catch is CancellationError { - self.flushPendingRows() + self.buffer.flush() self.finalizeTiming() - self.phase = .truncated(reason: .cancelled) + self.phase = .truncated(reason: self.buffer.truncation ?? .cancelled) } catch { - self.flushPendingRows() + self.buffer.flush() self.finalizeTiming() self.phase = .error(self.classify(error: error)) } @@ -95,17 +110,8 @@ final class QueryEditorViewModel { func reset() { fetchTask?.cancel() - flushTask?.cancel() - flushTask = nil - columns = [] - window.clear() - legacyRows.removeAll(keepingCapacity: true) - rowsReceived = 0 - rowsAffected = nil - statusMessage = nil + buffer.reset() executionTime = 0 - pendingRows.removeAll(keepingCapacity: true) - pendingRowsReceived = 0 phase = .idle } @@ -113,74 +119,32 @@ final class QueryEditorViewModel { await MainActor.run { switch level { case .normal: - break - case .warning: - Self.logger.warning("Memory pressure warning: shrinking editor window to 100 rows") - self.window.shrink(to: 100) - self.shrinkLegacyRows(to: 100) - case .critical: - Self.logger.error("Memory pressure critical: cancelling editor stream and shrinking to 50 rows") - self.window.shrink(to: 50) - self.shrinkLegacyRows(to: 50) + return + case .warning, .critical: + 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) + if case .running = self.phase { + self.phase = .truncated(reason: .memoryPressure) + } } } } - private func shrinkLegacyRows(to count: Int) { - guard legacyRows.count > count else { return } - legacyRows.removeFirst(legacyRows.count - count) + private func isMemoryConstrained() -> Bool { + if MemoryPressureMonitor.shared.currentLevel != .normal { return true } + return !MemoryPressureMonitor.shared.hasHeadroom(forBytes: Self.memorySafetyMarginBytes) } - private func apply(element: StreamElement) { - switch element { - case .columns(let cols): - columns = cols - case .row(let row): - pendingRows.append(row) - pendingRowsReceived += 1 - scheduleFlushIfNeeded() - case .rowsAffected(let count): - flushPendingRows() - rowsAffected = count - case .statusMessage(let message): - flushPendingRows() - statusMessage = message - case .truncated(let reason): - flushPendingRows() + private func resolvePhase() { + if let reason = buffer.truncation { phase = .truncated(reason: reason) + } else if case .running = phase { + phase = .finished } } - private func scheduleFlushIfNeeded() { - if pendingRows.count >= Self.flushBatchSize { - flushPendingRows() - return - } - if flushTask == nil { - flushTask = Task { [weak self] in - try? await Task.sleep(for: Self.flushInterval) - guard !Task.isCancelled else { return } - self?.flushPendingRows() - } - } - } - - private func flushPendingRows() { - flushTask?.cancel() - flushTask = nil - guard !pendingRows.isEmpty else { return } - let legacyBatch = pendingRows.map(\.legacyValues) - window.append(contentsOf: pendingRows) - legacyRows.append(contentsOf: legacyBatch) - if legacyRows.count > window.count { - let drop = legacyRows.count - window.count - legacyRows.removeFirst(drop) - } - rowsReceived = pendingRowsReceived - pendingRows.removeAll(keepingCapacity: true) - } - private func finalizeTiming() { if let startedAt { executionTime = Date().timeIntervalSince(startedAt) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 690d2d128..cbc3d8385 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -72,6 +72,12 @@ struct QueryEditorView: View { coordinator.pendingQuery = nil } } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in + Task { await viewModel.handlePressure(.warning) } + } + .onChange(of: MemoryPressureMonitor.shared.currentLevel) { _, level in + Task { await viewModel.handlePressure(level) } + } .alert(String(localized: "Write Query Blocked"), isPresented: $showWriteBlockedAlert) { Button("OK", role: .cancel) {} } message: { @@ -215,7 +221,13 @@ struct QueryEditorView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - resultList + VStack(spacing: 0) { + if let message = viewModel.truncationMessage { + truncationBanner(message) + Divider() + } + resultList + } } } else { ContentUnavailableView { @@ -228,6 +240,20 @@ struct QueryEditorView: View { } } + private func truncationBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(verbatim: message) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.thinMaterial) + } + private var resultList: some View { let indexed = IndexedRow.wrap(viewModel.legacyRows) return List { diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift new file mode 100644 index 000000000..07ce1959c --- /dev/null +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +import TableProDatabase +import TableProModels +@testable import TableProMobile + +@MainActor +@Suite("QueryEditorViewModel") +struct QueryEditorViewModelTests { + + private func makeColumns() -> [ColumnInfo] { + [ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0)] + } + + @Test("run caps a large result and keeps the first rows") + func runCapsAndKeepsHead() async { + let driver = MockDatabaseDriver() + let rows = (0..<10).map { ["\($0)"] } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: rows, rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, query: "SELECT * FROM t", maxRows: 3) + + #expect(vm.legacyRows.count == 3) + #expect(vm.legacyRows.first?.first == "0") + #expect(vm.legacyRows.last?.first == "2") + #expect(vm.truncationReason != nil) + #expect(vm.truncationMessage != nil) + if case .truncated(let reason) = vm.phase, case .rowCap(let cap) = reason { + #expect(cap == 3) + } else { + Issue.record("expected truncated(.rowCap) phase, got \(vm.phase)") + } + } + + @Test("run completes without truncation for a small result") + func runCompletes() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1"], ["2"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + + #expect(vm.legacyRows.count == 2) + #expect(vm.truncationReason == nil) + #expect(vm.truncationMessage == nil) + if case .finished = vm.phase {} else { + Issue.record("expected finished phase, got \(vm.phase)") + } + } + + @Test("handlePressure marks results truncated when rows are present") + func pressureMarksTruncated() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1"], ["2"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + #expect(vm.truncationReason == nil) + + await vm.handlePressure(.warning) + + #expect(vm.legacyRows.count == 2) + if case .memoryPressure = vm.truncationReason {} else { + Issue.record("expected memoryPressure truncation") + } + } + + @Test("reset clears rows and returns to idle") + func resetClears() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + #expect(vm.legacyRows.count == 1) + + vm.reset() + + #expect(vm.legacyRows.isEmpty) + #expect(vm.columns.isEmpty) + if case .idle = vm.phase {} else { + Issue.record("expected idle phase after reset") + } + } +} diff --git a/TableProMobile/TableProMobileTests/StreamingResultBufferTests.swift b/TableProMobile/TableProMobileTests/StreamingResultBufferTests.swift new file mode 100644 index 000000000..141c959b6 --- /dev/null +++ b/TableProMobile/TableProMobileTests/StreamingResultBufferTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +import TableProModels +@testable import TableProMobile + +@MainActor +@Suite("StreamingResultBuffer") +struct StreamingResultBufferTests { + + @Test("flush appends pending rows and apply records columns") + func flushAppends() { + let buffer = StreamingResultBuffer(capacity: 100) + buffer.apply(.columns([ColumnInfo(name: "id", typeName: "INT", ordinalPosition: 0)])) + buffer.apply(.row(Row(cells: [.text("1")]))) + buffer.apply(.row(Row(cells: [.text("2")]))) + buffer.flush() + + #expect(buffer.legacyRows.count == 2) + #expect(buffer.window.count == 2) + #expect(buffer.columns.count == 1) + #expect(buffer.legacyRows.first?.first == "1") + } + + @Test("shrink keeps window and legacy rows in lockstep") + func shrinkLockstep() { + let buffer = StreamingResultBuffer(capacity: 100) + for index in 0..<10 { + buffer.apply(.row(Row(cells: [.text("\(index)")]))) + } + buffer.flush() + #expect(buffer.legacyRows.count == 10) + + buffer.shrink(to: 4) + + #expect(buffer.legacyRows.count == 4) + #expect(buffer.window.count == 4) + } + + @Test("reset clears all state") + func resetClears() { + let buffer = StreamingResultBuffer(capacity: 100) + buffer.apply(.columns([ColumnInfo(name: "id", typeName: "INT", ordinalPosition: 0)])) + buffer.apply(.row(Row(cells: [.text("1")]))) + buffer.flush() + buffer.markTruncated(.memoryPressure) + + buffer.reset() + + #expect(buffer.isEmpty) + #expect(buffer.columns.isEmpty) + #expect(buffer.truncation == nil) + } +} From eb87d7a3c8ff440c2ac7dd6b441e4306e1d51e99 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 23:38:43 +0700 Subject: [PATCH 2/2] fix(ios): un-latch memory pressure monitor and gate editor truncation --- .../Platform/MemoryPressureMonitor.swift | 17 ++++++++++------- .../ViewModels/QueryEditorViewModel.swift | 8 +++----- .../QueryEditorViewModelTests.swift | 10 ++++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift index 05d856103..fe00b0987 100644 --- a/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift +++ b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift @@ -23,14 +23,21 @@ final class MemoryPressureMonitor { guard source == nil else { return } let newSource = DispatchSource.makeMemoryPressureSource( - eventMask: [.warning, .critical], + eventMask: [.normal, .warning, .critical], queue: .global(qos: .utility) ) newSource.setEventHandler { [weak self] in let event = newSource.data - let level: Level = event.contains(.critical) ? .critical : .warning - Self.logger.warning("Memory pressure event: \(String(describing: level), privacy: .public)") + let level: Level + if event.contains(.critical) { + level = .critical + } else if event.contains(.warning) { + level = .warning + } else { + level = .normal + } + Self.logger.log("Memory pressure level: \(String(describing: level), privacy: .public)") Task { @MainActor in self?.currentLevel = level } @@ -40,10 +47,6 @@ final class MemoryPressureMonitor { source = newSource } - func reset() { - currentLevel = .normal - } - nonisolated func availableMemoryBytes() -> Int { Int(os_proc_available_memory()) } diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index c79a5a44d..eb8c5e3be 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -121,20 +121,18 @@ final class QueryEditorViewModel { case .normal: return 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) - if case .running = self.phase { - self.phase = .truncated(reason: .memoryPressure) - } + self.phase = .truncated(reason: .memoryPressure) } } } private func isMemoryConstrained() -> Bool { - if MemoryPressureMonitor.shared.currentLevel != .normal { return true } - return !MemoryPressureMonitor.shared.hasHeadroom(forBytes: Self.memorySafetyMarginBytes) + !MemoryPressureMonitor.shared.hasHeadroom(forBytes: Self.memorySafetyMarginBytes) } private func resolvePhase() { diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index 07ce1959c..99a3a9897 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -53,8 +53,8 @@ struct QueryEditorViewModelTests { } } - @Test("handlePressure marks results truncated when rows are present") - func pressureMarksTruncated() async { + @Test("memory pressure after a clean finish does not relabel the result") + func pressureDoesNotRelabelFinishedResult() async { let driver = MockDatabaseDriver() driver.scriptedExecuteResults = [ .success(QueryResult(columns: makeColumns(), rows: [["1"], ["2"]], rowsAffected: 0, executionTime: 0)) @@ -67,8 +67,10 @@ struct QueryEditorViewModelTests { await vm.handlePressure(.warning) #expect(vm.legacyRows.count == 2) - if case .memoryPressure = vm.truncationReason {} else { - Issue.record("expected memoryPressure truncation") + #expect(vm.truncationReason == nil) + #expect(vm.truncationMessage == nil) + if case .finished = vm.phase {} else { + Issue.record("a completed result must stay finished after a memory warning") } }