Open SwiftData and TipKit stores after didFinishLaunching on iOS

TestFlight crashpoint "_hasTableWithName: + 12" (0xdead10cc) kills the
app ~1s after launch while ModelContainer.init runs WAL recovery from
YatteeApp.init(), before UIApplicationMain. That init also runs during
prewarm launches that iOS can suspend, and the suspended process holds
the store's SQLite lock. In some reports TipKit was opening its own
store from Tips.configure at the same time.

On iOS, DataManager now opens its store lazily, and AppDelegate's
didFinishLaunching configures TipKit and opens the store inside an
OpenDataStore background task. macOS and tvOS keep opening at init.
In-memory stores (tests, previews) open immediately.

Instrumentation: launch logs record prewarm, launch options, whether
the background task was granted, and open duration and trigger. Debug
iOS builds assert before opening if anything touches the store ahead
of didFinishLaunching.

This is a mitigation, not a confirmed fix; watch the crashpoint in the
next build. Moving the store out of the app group is deliberately not
done.
This commit is contained in:
Arkadiusz Fal
2026-10-31 19:50:30 +01:00
parent 03599d33b4
commit 8ee5d81a99
3 changed files with 190 additions and 49 deletions

View File

@@ -16,6 +16,39 @@ class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Open the SwiftData stores (TipKit's and the app's) here rather than in
// `YatteeApp.init()`, which also runs during prewarm launches that iOS
// suspends before `UIApplicationMain` (0xdead10cc if an SQLite lock is
// held). The assertion keeps a background launch running until the
// opens, including any WAL recovery, have finished and released locks.
DataManager.didFinishLaunching = true
var storeTask: UIBackgroundTaskIdentifier = .invalid
storeTask = application.beginBackgroundTask(withName: "OpenDataStore") {
LoggingService.shared.warning("Background task OpenDataStore expired", category: .diagnostics)
if storeTask != .invalid {
application.endBackgroundTask(storeTask)
storeTask = .invalid
}
}
LoggingService.shared.info(
"didFinishLaunching",
category: .diagnostics,
details: "applicationState: \(application.applicationState.rawValue), "
+ "prewarm: \(ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"), "
+ "launchOptions: \(launchOptions?.keys.map(\.rawValue).sorted() ?? []), "
// `.invalid` means iOS granted no background time. The stores
// are opened anyway — deferring would only move the same
// unprotected open to the first caller (e.g. a silent-push
// sync) — but the log makes that launch identifiable.
+ "assertion: \(storeTask != .invalid)"
)
YatteeApp.configureTips()
AppEnvironment.shared.dataManager.openStore(trigger: "didFinishLaunching")
if storeTask != .invalid {
application.endBackgroundTask(storeTask)
storeTask = .invalid
}
// Required for CKSyncEngine to receive silent push notifications for remote changes.
// The remote-notification background mode is already in Info.plist.
LoggingService.shared.logCloudKit("Requesting remote notification registration...")

View File

@@ -14,9 +14,28 @@ import SwiftData
final class DataManager {
// MARK: - Properties
let modelContainer: ModelContainer
let modelContext: ModelContext
/// The SwiftData container, opened on first access.
///
/// The on-disk store lives in the app-group container. Opening it takes an
/// SQLite lock (and runs WAL recovery after an unclean exit), and iOS kills
/// an app suspended while holding such a lock with 0xdead10cc. `DataManager`
/// is built from `YatteeApp.init()`, which also runs during prewarm launches
/// that the system suspends before `UIApplicationMain`, so the store must not
/// be opened there. On iOS `AppDelegate` opens it from
/// `didFinishLaunching` inside a background-task assertion via `openStore`.
var modelContainer: ModelContainer { openedStore().container }
/// The main-actor context of `modelContainer`, opened on first access.
var modelContext: ModelContext { openedStore().context }
@ObservationIgnored private var store: (container: ModelContainer, context: ModelContext)?
@ObservationIgnored private let isInMemory: Bool
@ObservationIgnored private let launchedAt = Date()
/// Set by `AppDelegate` once `didFinishLaunching` runs, so the open log can
/// tell a deferred open from one that still happened during launch.
static var didFinishLaunching = false
/// Weak reference to settings manager for accessing search history limit.
weak var settingsManager: SettingsManager?
@@ -34,7 +53,12 @@ final class DataManager {
/// to the sync engine — otherwise it only fetches changes made since the
/// old tokens and records that did not change since (subscriptions,
/// bookmarks) never come back.
private(set) var storeWasCreated: Bool = false
var storeWasCreated: Bool {
_ = openedStore()
return storeWasCreatedValue
}
@ObservationIgnored private var storeWasCreatedValue = false
/// Cached set of bookmarked video IDs for fast O(1) lookup.
/// Updated when bookmarks are added/removed locally or via CloudKit sync.
@@ -56,49 +80,21 @@ final class DataManager {
// MARK: - Initialization
init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws {
let configuration: ModelConfiguration
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
// the same data is created on multiple devices (each device generates different IDs).
//
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
// This approach:
// 1. Uses business identifiers (channelID, videoID) for deduplication
// 2. Gives explicit control over sync timing and conflict resolution
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
if inMemory {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
} else {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
}
let storeExisted = inMemory || FileManager.default.fileExists(atPath: configuration.url.path)
self.modelContainer = try ModelContainer(
for: Self.schema,
configurations: [configuration]
)
self.storeWasCreated = !storeExisted
self.modelContext = modelContainer.mainContext
self.modelContext.autosaveEnabled = true
self.isInMemory = inMemory
self.isCloudKitEnabled = false
LoggingService.shared.logCloudKit(
"SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)",
details: inMemory ? "in-memory" : "store: \(configuration.url.path), existed: \(storeExisted)"
)
// Initialize bookmark cache
refreshBookmarkCache()
// In-memory stores hold no file lock, so tests and previews open
// immediately and keep the synchronous behavior they rely on.
// macOS has no suspend-time lock kill and tvOS has no crash evidence,
// so both keep opening at init until there is a reason to change them.
#if os(iOS)
if inMemory {
openStore(trigger: "in-memory init")
}
#else
openStore(trigger: "init")
#endif
// Listen for bookmark changes from CloudKit sync to refresh cache
NotificationCenter.default.addObserver(
forName: .bookmarksDidChange,
@@ -111,7 +107,109 @@ final class DataManager {
}
}
}
// MARK: - Store Opening
/// Opens the store if it is not open yet. Safe to call repeatedly.
///
/// - Parameter trigger: Who asked, recorded in the open log so a store
/// opened before `didFinishLaunching` can be traced to its caller.
func openStore(trigger: String) {
guard store == nil else { return }
#if DEBUG && os(iOS)
// Enforces the lifecycle boundary in development, before SQLite takes
// any lock: anything that touches the store from `YatteeApp.init()` /
// `AppEnvironment.init` would reintroduce the pre-UIApplicationMain
// open (0xdead10cc).
if !isInMemory && !Self.didFinishLaunching {
LoggingService.shared.warning(
"SwiftData store opening before didFinishLaunching",
category: .diagnostics,
details: Thread.callStackSymbols.prefix(12).joined(separator: "\n")
)
assertionFailure("SwiftData store opening before didFinishLaunching (trigger: \(trigger))")
}
#endif
let start = Date()
let opened = Self.makeStore(inMemory: isInMemory)
store = (opened.container, opened.container.mainContext)
store?.context.autosaveEnabled = true
storeWasCreatedValue = opened.created
if !isInMemory {
let duration = Date().timeIntervalSince(start)
let sinceLaunch = start.timeIntervalSince(launchedAt)
let prewarm = ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"
LoggingService.shared.info(
"SwiftData store opened",
category: .diagnostics,
details: "trigger: \(trigger), duration: \(String(format: "%.3f", duration))s, "
+ "afterInit: \(String(format: "%.3f", sinceLaunch))s, "
+ "didFinishLaunching: \(Self.didFinishLaunching), prewarm: \(prewarm), "
+ "existed: \(!opened.created), store: \(opened.url?.path ?? "in-memory")"
)
}
refreshBookmarkCache()
}
private func openedStore() -> (container: ModelContainer, context: ModelContext) {
if store == nil {
openStore(trigger: "lazy access")
}
guard let store else { preconditionFailure("openStore always sets the store") }
return store
}
/// Builds the container, falling back to in-memory storage if the
/// persistent store cannot be opened.
private static func makeStore(inMemory: Bool) -> (container: ModelContainer, created: Bool, url: URL?) {
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
// the same data is created on multiple devices (each device generates different IDs).
//
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
// This approach:
// 1. Uses business identifiers (channelID, videoID) for deduplication
// 2. Gives explicit control over sync timing and conflict resolution
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
let memoryConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
if inMemory {
// swiftlint:disable:next force_try
let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration])
return (container, false, nil)
}
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
let storeExisted = FileManager.default.fileExists(atPath: configuration.url.path)
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
LoggingService.shared.logCloudKit(
"SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)",
details: "store: \(configuration.url.path), existed: \(storeExisted)"
)
return (container, !storeExisted, configuration.url)
} catch {
LoggingService.shared.logCloudKitError(
"Persistent store unavailable, falling back to in-memory storage",
error: error
)
// swiftlint:disable:next force_try
let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration])
return (container, false, nil)
}
}
/// Refreshes the cached set of bookmarked video IDs.
/// Called at init and when bookmarks change via CloudKit sync.
private func refreshBookmarkCache() {

View File

@@ -46,13 +46,23 @@ struct YatteeApp: App {
// Ambiguous-link prompts are stored on NavigationCoordinator so they can
// be dual-hosted by the root app view (here) and the expanded player sheet.
#if !os(tvOS)
/// Configures TipKit. Called once per launch.
static func configureTips() {
try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)])
}
#endif
init() {
// Configure Nuke image loading pipeline
ImageLoadingService.shared.configure()
#if !os(tvOS)
// TipKit: one-time hints such as the video info "tap thumbnail to play" tip
try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)])
// TipKit: one-time hints such as the video info "tap thumbnail to play" tip.
// TipKit opens its own SwiftData store, so on iOS it is configured from
// `AppDelegate.didFinishLaunching` together with the app store, never
// here: this init also runs during prewarm launches (0xdead10cc).
#if os(macOS)
Self.configureTips()
#endif
// Register background tasks early — Apple requires BGTaskScheduler.register()