Background 0xdead10cc crash fix

This commit fixes the background crashes with termination code
0xdead10cc.

Those crashes were caused by the fact that NostrDB was being stored on
the shared app container (Because our app extensions need NostrDB
data), and iOS kills any process that holds a file lock after the
process is backgrounded.

Other developers in the field have run into similar problems in the past
(with shared SQLite databases or shared SwiftData), and they generally
recommend not to place those database in shared containers at all,
mentioning that 0xdead10cc crashes are almost inevitable otherwise:

- https://ryanashcraft.com/sqlite-databases-in-app-group-containers/
- https://inessential.com/2020/02/13/how_we_fixed_the_dreaded_0xdead10cc_cras.html

Since iOS aggressively backgrounds and terminates processes with tight
timing constraints that are mostly outside our control (despite using
Apple's recommended mechanisms, such as requesting more time to perform
closing operations), this fix aims to address the issue by a different
storage architecture.

Instead of keeping NostrDB data on the shared app container and handling
the closure/opening of the database with the app lifecycle signals, keep
the main NostrDB database file in the app's private container, and instead
take periodic read-only snapshots of NostrDB in the shared container, so as
to allow extensions to have recent NostrDB data without all the
complexities of keeping the main file in the shared container.

This does have the tradeoff that more storage will be used by NostrDB
due to file duplication, but that can be mitigated via other techniques
if necessary.

Closes: https://github.com/damus-io/damus/issues/2638
Closes: https://github.com/damus-io/damus/issues/3463
Changelog-Fixed: Fixed background crashes with error code 0xdead10cc
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-12-29 15:43:47 -08:00
parent 0cbeaf8ea8
commit 368f94a209
16 changed files with 1010 additions and 62 deletions

View File

@@ -59,17 +59,12 @@ class NostrNetworkManager {
await self.pool.disconnect()
}
func handleAppBackgroundRequest(beforeClosingNdb operationBeforeClosingNdb: (() async -> Void)? = nil) async {
// Mark NDB as closed without actually closing it, to avoid new tasks from using NostrDB
self.delegate.ndb.markClosed()
func handleAppBackgroundRequest() async {
await self.reader.cancelAllTasks()
await self.pool.cleanQueuedRequestForSessionEnd()
await operationBeforeClosingNdb?()
self.delegate.ndb.close()
}
func handleAppForegroundRequest() async {
self.delegate.ndb.reopen()
// Pinging the network will automatically reconnect any dead websocket connections
await self.ping()
}

View File

@@ -39,6 +39,7 @@ class DamusState: HeadlessDamusState, ObservableObject {
let emoji_provider: EmojiProvider
let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager
var snapshotManager: DatabaseSnapshotManager
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, contactCards: ContactCard, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache, addNdbToRelayPool: Bool = true) {
self.keypair = keypair
@@ -77,12 +78,13 @@ class DamusState: HeadlessDamusState, ObservableObject {
let nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate, addNdbToRelayPool: addNdbToRelayPool)
self.nostrNetwork = nostrNetwork
self.wallet.nostrNetwork = nostrNetwork
self.snapshotManager = .init(ndb: ndb)
}
@MainActor
convenience init?(keypair: Keypair) {
convenience init?(keypair: Keypair, owns_db_file: Bool) {
// nostrdb
var mndb = Ndb()
var mndb = Ndb(owns_db_file: owns_db_file)
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")

View File

@@ -0,0 +1,195 @@
//
// DatabaseSnapshotManager.swift
// damus
//
// Created on 2025-01-20.
//
import Foundation
import OSLog
/// Manages periodic snapshots of the main NostrDB database to a shared container location.
///
/// This allows app extensions (like notification service extensions) to access a recent
/// read-only copy of the database for enhanced UX, while the main database resides in
/// the private container to avoid 0xdead10cc crashes and issues related to holding file locks on shared containers.
///
/// Snapshots are created periodically while the app is in the foreground, since the database
/// only gets updated when the app is active.
actor DatabaseSnapshotManager {
/// Minimum interval between snapshots (in seconds)
private static let minimumSnapshotInterval: TimeInterval = 60 * 60 // 1 hour
/// Key for storing last snapshot timestamp in UserDefaults
private static let lastSnapshotDateKey = "lastDatabaseSnapshotDate"
private let ndb: Ndb
private var snapshotTimerTask: Task<Void, Never>? = nil
var snapshotTimerTickCount: Int = 0
var snapshotCount: Int = 0
/// Initialize the snapshot manager with a NostrDB instance
/// - Parameter ndb: The NostrDB instance to snapshot
init(ndb: Ndb) {
self.ndb = ndb
}
// MARK: - Periodic tasks management
/// Start the periodic snapshot timer.
///
/// This should be called when the app enters the foreground.
/// The timer will fire periodically to check if a snapshot is needed.
func startPeriodicSnapshots() {
// Don't start if already running
guard snapshotTimerTask == nil else {
Log.debug("Snapshot timer already running", for: .storage)
return
}
Log.info("Starting periodic database snapshot timer", for: .storage)
snapshotTimerTask = Task(priority: .utility) { [weak self] in
while !Task.isCancelled {
guard let self else { return }
Log.debug("Snapshot timer - tick", for: .storage)
await self.increaseSnapshotTimerTickCount()
do {
try await self.createSnapshotIfNeeded()
}
catch {
Log.error("Failed to create snapshot: %{public}@", for: .storage, error.localizedDescription)
}
try? await Task.sleep(for: .seconds(60 * 5), tolerance: .seconds(10))
}
}
}
/// Stop the periodic snapshot timer.
///
/// This should be called when the app enters the background.
func stopPeriodicSnapshots() async {
guard snapshotTimerTask != nil else {
return
}
Log.info("Stopping periodic database snapshot timer", for: .storage)
snapshotTimerTask?.cancel()
await snapshotTimerTask?.value
snapshotTimerTask = nil
}
// MARK: - Snapshotting
/// Perform a database snapshot if needed.
///
/// This method checks if enough time has passed since the last snapshot and creates a new one if necessary.
@discardableResult
func createSnapshotIfNeeded() async throws -> Bool {
guard shouldCreateSnapshot() else {
Log.debug("Skipping snapshot - minimum interval not yet elapsed", for: .storage)
return false
}
try await self.performSnapshot()
return true
}
/// Check if a snapshot should be created based on the last snapshot time.
private func shouldCreateSnapshot() -> Bool {
guard let lastSnapshotDate = UserDefaults.standard.object(forKey: Self.lastSnapshotDateKey) as? Date else {
return true // No snapshot has been created yet
}
let timeSinceLastSnapshot = Date().timeIntervalSince(lastSnapshotDate)
return timeSinceLastSnapshot >= Self.minimumSnapshotInterval
}
/// Perform the actual snapshot operation.
func performSnapshot() async throws {
guard let snapshotPath = Ndb.snapshot_db_path else {
throw SnapshotError.pathsUnavailable
}
Log.info("Starting nostrdb snapshot to %{public}@", for: .storage, snapshotPath)
try await copyDatabase(to: snapshotPath)
// Update the last snapshot date
UserDefaults.standard.set(Date(), forKey: Self.lastSnapshotDateKey)
Log.info("Database snapshot completed successfully", for: .storage)
self.snapshotCount += 1
}
/// Copy the database using LMDB's native copy function.
private func copyDatabase(to snapshotPath: String) async throws {
return try await withCheckedThrowingContinuation { continuation in
let fileManager = FileManager.default
// Delete existing database files at the destination if they exist
// LMDB creates multiple files (data.mdb, lock.mdb), so we remove the entire directory
if fileManager.fileExists(atPath: snapshotPath) {
do {
try fileManager.removeItem(atPath: snapshotPath)
Log.debug("Removed existing snapshot at %{public}@", for: .storage, snapshotPath)
} catch {
continuation.resume(throwing: SnapshotError.removeFailed(error))
return
}
}
Log.debug("Recreate the snapshot directory", for: .storage, snapshotPath)
// Recreate the snapshot directory
do {
try fileManager.createDirectory(atPath: snapshotPath, withIntermediateDirectories: true)
} catch {
continuation.resume(throwing: SnapshotError.directoryCreationFailed(error))
return
}
do {
try ndb.snapshot(path: snapshotPath)
continuation.resume(returning: ())
}
catch {
continuation.resume(throwing: SnapshotError.copyFailed(error))
}
}
}
// MARK: - Stats functions
private func increaseSnapshotTimerTickCount() async {
self.snapshotTimerTickCount += 1
}
func resetStats() async {
self.snapshotTimerTickCount = 0
self.snapshotCount = 0
}
}
// MARK: - Error Types
enum SnapshotError: Error, LocalizedError {
case pathsUnavailable
case copyFailed(any Error)
case removeFailed(Error)
case directoryCreationFailed(Error)
var errorDescription: String? {
switch self {
case .pathsUnavailable:
return "Database paths are not available"
case .copyFailed(let code):
return "Failed to copy database (error code: \(code))"
case .removeFailed(let error):
return "Failed to remove existing snapshot: \(error.localizedDescription)"
case .directoryCreationFailed(let error):
return "Failed to create snapshot directory: \(error.localizedDescription)"
}
}
}