From cb7045ba066f9666eb468c1a6e1dc11b321ee320 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Sat, 11 Apr 2026 17:20:34 -0600 Subject: [PATCH 1/2] Move airport/runway fetch to background context in BasePerformanceViewModel Contributes to fixing SF50-TOLD-1X (DB on main thread). The airport and runway observation task in setupObservation() previously used container.mainContext for findAirportAndRunway fetches, causing SwiftData queries to run on the main thread. Replaces the stored context property with container. The observation task is now Task.detached, fetching on a fresh background ModelContext per iteration. Results are returned as PersistentIdentifier values that cross actor boundaries cleanly, then rehydrated on MainActor via mainContext.model(for:) for property assignment. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ViewModel/BasePerformanceViewModel.swift | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift index 25fc1d9..02755db 100644 --- a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift +++ b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift @@ -43,7 +43,7 @@ open class BasePerformanceViewModel: WithIdentifiableError { private static let logger = Logger(label: "codes.tim.SF50-TOLD.BasePerformanceViewModel") - internal let context: ModelContext + private let container: ModelContainer internal var model: PerformanceModel? private var cancellables: Set> = [] private var runwayNOTAMObservationTask: Task? @@ -134,7 +134,7 @@ open class BasePerformanceViewModel: WithIdentifiableError { calculationService: PerformanceCalculationService = DefaultPerformanceCalculationService.shared, defaultFlapSetting: FlapSetting ) { - context = container.mainContext + self.container = container self.calculationService = calculationService // temporary values, overwritten by recalculate() @@ -150,29 +150,57 @@ open class BasePerformanceViewModel: WithIdentifiableError { setupObservation() } + // MARK: - Background Fetching + + /// Searches for airport and runway on a background context, returning their + /// persistent identifiers so results can be applied on the main actor without + /// sending non-Sendable model objects across isolation boundaries. + nonisolated private static func fetchAirportAndRunwayIDs( + airportID: String?, + runwayID: String?, + container: ModelContainer + ) throws -> (airport: PersistentIdentifier?, runway: PersistentIdentifier?, airportFound: Bool) { + let context = ModelContext(container) + let (airport, runway) = try findAirportAndRunway( + airportID: airportID, + runwayID: runwayID, + in: context + ) + return (airport?.persistentModelID, runway?.persistentModelID, airport != nil) + } + // MARK: - Observation Setup private func setupObservation() { - // Observe airport and runway changes + // Observe airport and runway changes (fetched off the main thread) + let airportKey = airportDefaultsKey + let runwayKey = runwayDefaultsKey addTask( - Task { - for await (airportID, runwayID) in Defaults.updates(airportDefaultsKey, runwayDefaultsKey) + Task.detached { [container] in + for await (airportID, runwayID) in Defaults.updates(airportKey, runwayKey) where !Task.isCancelled { do { - let (airport, runway) = try findAirportAndRunway( + let ids = try Self.fetchAirportAndRunwayIDs( airportID: airportID, runwayID: runwayID, - in: context + container: container ) - if airport == nil { Defaults[airportDefaultsKey] = nil } - if runway == nil { Defaults[runwayDefaultsKey] = nil } - self.airport = airport - self.runway = runway + await MainActor.run { + let mainContext = container.mainContext + let airport = ids.airport.flatMap { mainContext.model(for: $0) as? Airport } + let runway = ids.runway.flatMap { mainContext.model(for: $0) as? Runway } + if !ids.airportFound { Defaults[airportKey] = nil } + if runway == nil { Defaults[runwayKey] = nil } + self.airport = airport + self.runway = runway + } } catch { - SentrySDK.capture(error: error) { scope in - scope.setFingerprint(["swiftData", "fetch"]) + await MainActor.run { + SentrySDK.capture(error: error) { scope in + scope.setFingerprint(["swiftData", "fetch"]) + } + self.error = error } - self.error = error } } } From 2f9a42f286538f3c93efb6ab8187487c6be2774e Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Sun, 12 Apr 2026 13:47:55 -0600 Subject: [PATCH 2/2] Inline PersistentIdentifier extraction, remove nonisolated helper Drops the nonisolated static fetchAirportAndRunwayIDs helper in favor of inlining the fetch and PersistentIdentifier extraction directly in the Task.detached closure. SwiftData @Model types cannot be safely sent across isolation boundaries under strict concurrency, so PersistentIdentifier (a Sendable value type) is extracted on the background context and the model is rehydrated via mainContext.model(for:) on MainActor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ViewModel/BasePerformanceViewModel.swift | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift index 02755db..29c5b34 100644 --- a/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift +++ b/SF50 Shared/Performance/ViewModel/BasePerformanceViewModel.swift @@ -150,29 +150,9 @@ open class BasePerformanceViewModel: WithIdentifiableError { setupObservation() } - // MARK: - Background Fetching - - /// Searches for airport and runway on a background context, returning their - /// persistent identifiers so results can be applied on the main actor without - /// sending non-Sendable model objects across isolation boundaries. - nonisolated private static func fetchAirportAndRunwayIDs( - airportID: String?, - runwayID: String?, - container: ModelContainer - ) throws -> (airport: PersistentIdentifier?, runway: PersistentIdentifier?, airportFound: Bool) { - let context = ModelContext(container) - let (airport, runway) = try findAirportAndRunway( - airportID: airportID, - runwayID: runwayID, - in: context - ) - return (airport?.persistentModelID, runway?.persistentModelID, airport != nil) - } - // MARK: - Observation Setup private func setupObservation() { - // Observe airport and runway changes (fetched off the main thread) let airportKey = airportDefaultsKey let runwayKey = runwayDefaultsKey addTask( @@ -180,16 +160,19 @@ open class BasePerformanceViewModel: WithIdentifiableError { for await (airportID, runwayID) in Defaults.updates(airportKey, runwayKey) where !Task.isCancelled { do { - let ids = try Self.fetchAirportAndRunwayIDs( + let context = ModelContext(container) + let (fetchedAirport, fetchedRunway) = try findAirportAndRunway( airportID: airportID, runwayID: runwayID, - container: container + in: context ) + let airportPersistentID = fetchedAirport?.persistentModelID + let runwayPersistentID = fetchedRunway?.persistentModelID await MainActor.run { let mainContext = container.mainContext - let airport = ids.airport.flatMap { mainContext.model(for: $0) as? Airport } - let runway = ids.runway.flatMap { mainContext.model(for: $0) as? Runway } - if !ids.airportFound { Defaults[airportKey] = nil } + let airport = airportPersistentID.flatMap { mainContext.model(for: $0) as? Airport } + let runway = runwayPersistentID.flatMap { mainContext.model(for: $0) as? Runway } + if airport == nil { Defaults[airportKey] = nil } if runway == nil { Defaults[runwayKey] = nil } self.airport = airport self.runway = runway