From c15f8ca6bf11108f2c4a8f40cb44187a0814ab0e Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Sat, 11 Apr 2026 17:20:19 -0600 Subject: [PATCH 1/2] Move NavDataLoader polling to background context Fixes SF50-TOLD-20 (15-16s app hang) and contributes to SF50-TOLD-1X and SF50-TOLD-22 (DB on main thread). The polling loop in setupObservation() ran setAnyAirports every 500ms on container.mainContext, calling fetchNASRExpiration which also queried mainContext. During NavDataLoader batch writes, the main context's fetch blocked waiting for the persistent store coordinator lock, causing 15-16s main-thread hangs. This extracts a nonisolated fetchLoaderState(context:) helper that combines the Airport existence check and NASR expiration fetch into a value-type tuple. The polling loop now runs in Task.detached with its own ModelContext(container), reading via MVCC without blocking the main thread. Results are applied back on MainActor, skipping redundant property assignments to avoid unnecessary @Observable notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../NavDataLoaderViewModel.swift | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift index 309f06f..cba1221 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift @@ -75,19 +75,23 @@ final class NavDataLoaderViewModel: WithIdentifiableError { ) addTask( - Task { + Task.detached { [container] in do { - let context = container.mainContext - try setAnyAirports(context: context) + let context = ModelContext(container) while !Task.isCancelled { - try setAnyAirports(context: context) + let state = try self.fetchLoaderState(context: context) + await MainActor.run { + self.applyState(state) + } try? await Task.sleep(for: .seconds(0.5)) } } catch { - SentrySDK.capture(error: error) { scope in - scope.setFingerprint(["navData", "airportCheck"]) + await MainActor.run { + SentrySDK.capture(error: error) { scope in + scope.setFingerprint(["navData", "airportCheck"]) + } + self.error = error } - self.error = error } } ) @@ -182,25 +186,43 @@ final class NavDataLoaderViewModel: WithIdentifiableError { Defaults[.ourAirportsLastUpdated] = nil } - private func outOfDate(expirationDate: Date?) -> Bool { + nonisolated private func outOfDate(expirationDate: Date?) -> Bool { guard let expirationDate else { return true } return Date() > expirationDate } - private func outOfDate(schemaVersion: Int) -> Bool { + nonisolated private func outOfDate(schemaVersion: Int) -> Bool { schemaVersion != latestSchemaVersion } private func recalculate() throws { + let state = try fetchLoaderState(context: container.mainContext) + applyState(state) + } + + private func applyState(_ state: (noData: Bool, needsLoad: Bool, canSkip: Bool)) { + if noData != state.noData { self.noData = state.noData } + if needsLoad != state.needsLoad { self.needsLoad = state.needsLoad } + if canSkip != state.canSkip { self.canSkip = state.canSkip } + } + + nonisolated private func fetchLoaderState(context: ModelContext) throws -> ( + noData: Bool, needsLoad: Bool, canSkip: Bool + ) { + var airportDescriptor = FetchDescriptor() + airportDescriptor.fetchLimit = 1 + let noData = try context.fetch(airportDescriptor).isEmpty + let schemaOutOfDate = outOfDate(schemaVersion: Defaults[.schemaVersion]) - let nasrExpiration = try fetchNASRExpiration() + let nasrExpiration = try fetchNASRExpiration(context: context) let dataOutOfDate = outOfDate(expirationDate: nasrExpiration) - needsLoad = schemaOutOfDate || dataOutOfDate - canSkip = !noData && !schemaOutOfDate + + let needsLoad = schemaOutOfDate || dataOutOfDate + let canSkip = !noData && !schemaOutOfDate + return (noData: noData, needsLoad: needsLoad, canSkip: canSkip) } - private func fetchNASRExpiration() throws -> Date? { - let context = container.mainContext + nonisolated private func fetchNASRExpiration(context: ModelContext) throws -> Date? { let nasrRawValue = CycleDataSource.nasr.rawValue var descriptor = FetchDescriptor( predicate: #Predicate { $0._dataSource == nasrRawValue } @@ -208,11 +230,4 @@ final class NavDataLoaderViewModel: WithIdentifiableError { descriptor.fetchLimit = 1 return try context.fetch(descriptor).first?.expires } - - private func setAnyAirports(context: ModelContext) throws { - var descriptor = FetchDescriptor() - descriptor.fetchLimit = 1 - noData = try context.fetch(descriptor).isEmpty - try recalculate() - } } From 38dfa76c50c9b66f9584266eb157304ff31d5e70 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Sun, 12 Apr 2026 13:47:50 -0600 Subject: [PATCH 2/2] Replace nonisolated helpers with file-scope NavDataStateHelper Moves the four nonisolated helper methods out of NavDataLoaderViewModel into a private file-scope enum. File-scope types are nonisolated by default, so the helpers become callable from both MainActor and background contexts without annotations. Also introduces a named NavDataStateHelper.State struct in place of the previous (noData, needsLoad, canSkip) tuple for self-documenting call sites. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../NavDataLoaderViewModel.swift | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift index cba1221..e58731f 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift @@ -79,7 +79,7 @@ final class NavDataLoaderViewModel: WithIdentifiableError { do { let context = ModelContext(container) while !Task.isCancelled { - let state = try self.fetchLoaderState(context: context) + let state = try NavDataStateHelper.fetchState(context: context) await MainActor.run { self.applyState(state) } @@ -186,43 +186,40 @@ final class NavDataLoaderViewModel: WithIdentifiableError { Defaults[.ourAirportsLastUpdated] = nil } - nonisolated private func outOfDate(expirationDate: Date?) -> Bool { - guard let expirationDate else { return true } - return Date() > expirationDate - } - - nonisolated private func outOfDate(schemaVersion: Int) -> Bool { - schemaVersion != latestSchemaVersion - } - private func recalculate() throws { - let state = try fetchLoaderState(context: container.mainContext) + let state = try NavDataStateHelper.fetchState(context: container.mainContext) applyState(state) } - private func applyState(_ state: (noData: Bool, needsLoad: Bool, canSkip: Bool)) { + private func applyState(_ state: NavDataStateHelper.State) { if noData != state.noData { self.noData = state.noData } if needsLoad != state.needsLoad { self.needsLoad = state.needsLoad } if canSkip != state.canSkip { self.canSkip = state.canSkip } } +} - nonisolated private func fetchLoaderState(context: ModelContext) throws -> ( - noData: Bool, needsLoad: Bool, canSkip: Bool - ) { +/// File-scope helper for computing nav-data loader state from any `ModelContext`. +/// +/// Declared outside `NavDataLoaderViewModel` so it is nonisolated by default +/// and callable from both MainActor and background tasks without annotations. +private enum NavDataStateHelper { + static func fetchState(context: ModelContext) throws -> State { var airportDescriptor = FetchDescriptor() airportDescriptor.fetchLimit = 1 let noData = try context.fetch(airportDescriptor).isEmpty - let schemaOutOfDate = outOfDate(schemaVersion: Defaults[.schemaVersion]) + let schemaOutOfDate = Defaults[.schemaVersion] != latestSchemaVersion let nasrExpiration = try fetchNASRExpiration(context: context) - let dataOutOfDate = outOfDate(expirationDate: nasrExpiration) + let dataOutOfDate = nasrExpiration.map { Date() > $0 } ?? true - let needsLoad = schemaOutOfDate || dataOutOfDate - let canSkip = !noData && !schemaOutOfDate - return (noData: noData, needsLoad: needsLoad, canSkip: canSkip) + return State( + noData: noData, + needsLoad: schemaOutOfDate || dataOutOfDate, + canSkip: !noData && !schemaOutOfDate + ) } - nonisolated private func fetchNASRExpiration(context: ModelContext) throws -> Date? { + private static func fetchNASRExpiration(context: ModelContext) throws -> Date? { let nasrRawValue = CycleDataSource.nasr.rawValue var descriptor = FetchDescriptor( predicate: #Predicate { $0._dataSource == nasrRawValue } @@ -230,4 +227,10 @@ final class NavDataLoaderViewModel: WithIdentifiableError { descriptor.fetchLimit = 1 return try context.fetch(descriptor).first?.expires } + + struct State { + let noData: Bool + let needsLoad: Bool + let canSkip: Bool + } }