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

@@ -0,0 +1,325 @@
//
// DatabaseSnapshotManagerTests.swift
// damus
//
// Created by Daniel D'Aquino on 2026-01-02.
//
import XCTest
@testable import damus
final class DatabaseSnapshotManagerTests: XCTestCase {
var tempDirectory: URL!
var manager: DatabaseSnapshotManager!
var testNdb: Ndb!
override func setUp() async throws {
try await super.setUp()
// Create a temporary directory for test files
tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, conformingTo: .directory)
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
self.testNdb = Ndb(path: test_ndb_dir(), owns_db_file: true)!
// Create the manager
manager = DatabaseSnapshotManager(ndb: self.testNdb)
// Clear UserDefaults for consistent testing
UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate")
}
override func tearDown() async throws {
// Clean up temporary directory
if let tempDirectory = tempDirectory {
try? FileManager.default.removeItem(at: tempDirectory)
}
// Clear UserDefaults
UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate")
// Stop any running snapshots
await manager.stopPeriodicSnapshots()
manager = nil
tempDirectory = nil
try await super.tearDown()
}
// MARK: - Snapshot Creation Tests
func testCreateSnapshotIfNeeded_CreatesSnapshotWhenNeverCreatedBefore() async throws {
// Given: No previous snapshot exists
XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
// When: createSnapshotIfNeeded is called
try await manager.createSnapshotIfNeeded()
// Then: A snapshot should be created
XCTAssertNotNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
}
func testCreateSnapshotIfNeeded_SkipsSnapshotWhenRecentSnapshotExists() async throws {
// Given: A recent snapshot was just created
UserDefaults.standard.set(Date(), forKey: "lastDatabaseSnapshotDate")
// When: createSnapshotIfNeeded is called
let snapshotMade = try await manager.createSnapshotIfNeeded()
// Then: No snapshot should be created
XCTAssertFalse(snapshotMade)
}
func testCreateSnapshotIfNeeded_CreatesSnapshotWhenIntervalHasPassed() async throws {
// Given: A snapshot was created more than 1 hour ago
let oldDate = Date().addingTimeInterval(-60 * 61) // 61 minutes ago
UserDefaults.standard.set(oldDate, forKey: "lastDatabaseSnapshotDate")
// When: createSnapshotIfNeeded is called
let snapshotMade = try await manager.createSnapshotIfNeeded()
// Then: A snapshot should be created
XCTAssertTrue(snapshotMade)
// And: The last snapshot date should be updated
let lastDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date
XCTAssertNotNil(lastDate)
XCTAssertTrue(lastDate! > oldDate)
}
// MARK: - Perform Snapshot Tests
func testPerformSnapshot_WritesFile() async throws {
// Given: No previous snapshot exists
XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
let fileManager = FileManager.default
guard let snapshotPath = Ndb.snapshot_db_path else {
XCTFail("Snapshot path should be available")
return
}
try fileManager.removeItem(atPath: snapshotPath)
XCTAssertFalse(fileManager.fileExists(atPath: snapshotPath), "Snapshot directory should not exist at \(snapshotPath)")
// When: Creating a snapshot
let snapshotMade = try await manager.createSnapshotIfNeeded()
// Then: Snapshot should be created
XCTAssertTrue(snapshotMade)
// And: The snapshot should be there
var isDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: snapshotPath, isDirectory: &isDirectory)
XCTAssertTrue(exists, "Snapshot directory should exist at \(snapshotPath)")
XCTAssertTrue(isDirectory.boolValue, "Snapshot path should be a directory")
// And: LMDB database files should exist
let dataFile = "\(snapshotPath)/data.mdb"
XCTAssertTrue(fileManager.fileExists(atPath: dataFile), "data.mdb should exist")
}
func testPerformSnapshot_UpdatesTimestamp() async throws {
// Given: No previous snapshot
XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
let beforeDate = Date()
// When: Performing a snapshot
try await manager.performSnapshot()
let afterDate = Date()
// Then: The timestamp should be set and within the time window
let savedDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date
XCTAssertNotNil(savedDate)
XCTAssertGreaterThanOrEqual(savedDate!, beforeDate)
XCTAssertLessThanOrEqual(savedDate!, afterDate)
}
func testPerformSnapshot_CanBeCalledMultipleTimes() async throws {
// Given: A snapshot already exists
try await manager.performSnapshot()
// When: Performing another snapshot (this should replace the old one)
try await manager.performSnapshot()
// Then: No error should occur
XCTAssertNotNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
}
// MARK: - Periodic Snapshot Timer Tests
func testStartPeriodicSnapshots_StartsTimer() async throws {
// Given: Manager is initialized
// When: startPeriodicSnapshots is called
await manager.startPeriodicSnapshots()
// Give the timer task a moment to execute
try await Task.sleep(for: .milliseconds(100))
// Then: A snapshot should be attempted
let tickCount = await manager.snapshotTimerTickCount
XCTAssertGreaterThan(tickCount, 0)
}
func testStartPeriodicSnapshots_DoesNotStartMultipleTimes() async throws {
// Given: Timer is already started
await manager.startPeriodicSnapshots()
// Give the timer a moment to start
try await Task.sleep(for: .milliseconds(500))
let firstTickCount = await manager.snapshotTimerTickCount
// When: startPeriodicSnapshots is called again
await manager.startPeriodicSnapshots()
// Give it a moment
try await Task.sleep(for: .milliseconds(500))
let secondTickCount = await manager.snapshotTimerTickCount
// Then: The tick count should not have increased significantly
// (proving we didn't start a second timer)
XCTAssertEqual(secondTickCount, firstTickCount, "Starting twice should not create multiple timers")
}
func testStopPeriodicSnapshots_StopsTimer() async throws {
// Given: Timer is running
await manager.startPeriodicSnapshots()
// When: stopPeriodicSnapshots is called and stats are reset
await manager.stopPeriodicSnapshots()
await manager.resetStats()
// Wait longer than the timer interval
try await Task.sleep(for: .milliseconds(200))
// Then: No more snapshots should be created
let snapshotCount = await manager.snapshotCount
XCTAssertEqual(snapshotCount, 0)
}
func testStopPeriodicSnapshots_CanBeCalledMultipleTimes() async throws {
// Given: Timer is running
await manager.startPeriodicSnapshots()
// When: stopPeriodicSnapshots is called multiple times
await manager.stopPeriodicSnapshots()
await manager.stopPeriodicSnapshots()
// Then: No crash should occur (test passes if we get here)
XCTAssertTrue(true)
}
// MARK: - Integration Tests
func testSnapshotLifecycle_StartStopRestart() async throws {
// Given: A manager with valid configuration
// When: Starting, stopping, and restarting the timer
await manager.startPeriodicSnapshots()
try await Task.sleep(for: .milliseconds(100))
await manager.stopPeriodicSnapshots()
await manager.startPeriodicSnapshots()
try await Task.sleep(for: .milliseconds(1000))
// Then: Snapshots should be created appropriately
let snapshotCount = await manager.snapshotCount
XCTAssertGreaterThan(snapshotCount, 0)
}
func testSnapshotTimestampUpdates() async throws {
// Given: No previous snapshot
XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate"))
// When: Creating first snapshot
try await manager.performSnapshot()
let firstDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date
XCTAssertNotNil(firstDate)
// Wait to ensure time difference
try await Task.sleep(for: .milliseconds(500))
// Force another snapshot by clearing the date
UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate")
try await manager.performSnapshot()
let secondDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date
XCTAssertNotNil(secondDate)
// Then: Second date should be after first date
XCTAssertGreaterThan(secondDate!, firstDate!, "Second snapshot timestamp should be later than first")
}
// MARK: - Error Handling Tests
func testCreateSnapshotIfNeeded_HandlesErrors() async throws {
// This test verifies that errors from performSnapshot are propagated
// We can't easily test the actual error cases without mocking,
// but we verify the method signature allows throwing
// Given: A recent snapshot exists
UserDefaults.standard.set(Date(), forKey: "lastDatabaseSnapshotDate")
// When: Attempting to create a snapshot (should skip)
let result = try await manager.createSnapshotIfNeeded()
// Then: Should return false without throwing
XCTAssertFalse(result)
}
// MARK: - Edge Case Tests
func testSnapshotInterval_BoundaryCondition() async throws {
// Given: A snapshot was created exactly 1 hour ago (the minimum interval)
let exactlyOneHourAgo = Date().addingTimeInterval(-60 * 60)
UserDefaults.standard.set(exactlyOneHourAgo, forKey: "lastDatabaseSnapshotDate")
// When: Attempting to create a snapshot at the exact boundary
let shouldCreate = try await manager.createSnapshotIfNeeded()
// Then: A snapshot should be created (>= rather than > comparison)
XCTAssertTrue(shouldCreate, "Snapshot should be created when exactly at minimum interval")
}
func testSnapshotInterval_JustBeforeBoundary() async throws {
// Given: A snapshot was created 59 minutes and 59 seconds ago (just before the interval)
let justBeforeOneHour = Date().addingTimeInterval(-60 * 59 - 59)
UserDefaults.standard.set(justBeforeOneHour, forKey: "lastDatabaseSnapshotDate")
// When: Attempting to create a snapshot
let shouldCreate = try await manager.createSnapshotIfNeeded()
// Then: No snapshot should be created
XCTAssertFalse(shouldCreate, "Snapshot should not be created before minimum interval")
}
}
// MARK: - SnapshotError Equatable Conformance for Testing
extension SnapshotError: Equatable {
public static func == (lhs: SnapshotError, rhs: SnapshotError) -> Bool {
switch (lhs, rhs) {
case (.pathsUnavailable, .pathsUnavailable):
return true
case (.copyFailed, .copyFailed):
return true
case (.removeFailed, .removeFailed):
return true
case (.directoryCreationFailed, .directoryCreationFailed):
return true
default:
return false
}
}
}

View File

@@ -0,0 +1,253 @@
//
// NdbMigrationTests.swift
// damus
//
// Created by Daniel D'Aquino on 2026-01-02.
//
import XCTest
@testable import damus
final class NdbMigrationTests: XCTestCase {
var testDirectory: URL!
var legacyPath: String!
var privatePath: String!
override func setUp() async throws {
try await super.setUp()
// Create a temporary directory for tests
testDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("NdbMigrationTests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true)
// Set up test paths
legacyPath = testDirectory.appendingPathComponent("legacy").path
privatePath = testDirectory.appendingPathComponent("private").path
}
override func tearDown() async throws {
// Clean up test directory
if let testDirectory = testDirectory {
try? FileManager.default.removeItem(at: testDirectory)
}
try await super.tearDown()
}
// MARK: - Helper Methods
/// Creates mock database files in the specified directory
/// - Parameters:
/// - path: The directory path where database files should be created
/// - content: The content to write to the database files. If nil, uses a default content string
/// - modificationDate: The modification date to set on the data.mdb file
private func createMockDatabaseFiles(at path: String, content: String? = nil, modificationDate: Date = Date()) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true)
// Create both data.mdb and lock.mdb files
let dataMdbPath = "\(path)/data.mdb"
let lockMdbPath = "\(path)/lock.mdb"
// Write content (use provided content or default)
let fileContent = content ?? "Mock database content"
let dummyData = fileContent.data(using: .utf8)!
try dummyData.write(to: URL(fileURLWithPath: dataMdbPath))
try dummyData.write(to: URL(fileURLWithPath: lockMdbPath))
// Set modification date
try fileManager.setAttributes([.modificationDate: modificationDate], ofItemAtPath: dataMdbPath)
}
/// Verifies that database files exist at the specified path
private func verifyDatabaseFilesExist(at path: String) -> Bool {
let fileManager = FileManager.default
let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb")
let lockMdbExists = fileManager.fileExists(atPath: "\(path)/lock.mdb")
return dataMdbExists && lockMdbExists
}
/// Verifies that database files exist at the specified path
private func verifyDataDotMdbExists(at path: String) -> Bool {
let fileManager = FileManager.default
let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb")
return dataMdbExists
}
/// Verifies that database files do not exist at the specified path
private func verifyDatabaseFilesDoNotExist(at path: String) -> Bool {
let fileManager = FileManager.default
let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb")
let lockMdbExists = fileManager.fileExists(atPath: "\(path)/lock.mdb")
return !dataMdbExists && !lockMdbExists
}
// MARK: - Tests
func testDbMigrateIfNeeded_migratesFromLegacyToPrivate() throws {
// Given: Legacy database files exist with a newer modification date than private
let legacyModificationDate = Date()
let legacyContent = "Legacy database content"
try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate)
// Verify initial state: legacy files exist, private files don't
XCTAssertTrue(verifyDatabaseFilesExist(at: legacyPath), "Legacy database files should exist before migration")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist before migration")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Files should be migrated to private path
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist after migration")
// Verify the content was actually copied/moved
let privateDataContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(privateDataContent, legacyContent, "Migrated database content should match original")
// The original files should be gone (moved, not copied)
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration (files should be moved, not copied)")
}
func testDbMigrateIfNeeded_noMigrationWhenPrivateHasLatestFiles() throws {
// Given: Both locations have database files, but private has a newer modification date
let legacyModificationDate = Date(timeIntervalSinceNow: -3600) // 1 hour ago
let privateModificationDate = Date() // Now (newer)
let legacyContent = "Legacy database content"
let privateContent = "Private database content (newer)"
try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate)
try createMockDatabaseFiles(at: privatePath, content: privateContent, modificationDate: privateModificationDate)
// Store original private content to verify it doesn't change
let originalPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(originalPrivateContent, privateContent, "Initial private content should match")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Old files should be deleted to preserve storage
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should still exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not still exist, to save storage space (deleted)")
let currentPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(currentPrivateContent, privateContent, "Private database content should be unchanged")
XCTAssertNotEqual(currentPrivateContent, legacyContent, "Private content should not have been replaced with legacy content")
}
func testDbMigrateIfNeeded_noMigrationWhenOnlyPrivateFilesExist() throws {
// Given: Only private path has database files (no legacy files)
let privateModificationDate = Date()
let privateContent = "Private database content only"
try createMockDatabaseFiles(at: privatePath, content: privateContent, modificationDate: privateModificationDate)
// Verify initial state
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist")
let originalContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Nothing should change
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should still exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should still not exist")
let currentContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(currentContent, originalContent, "Private content should remain unchanged")
}
func testDbMigrateIfNeeded_noMigrationWhenNoDatabaseFilesExist() throws {
// Given: No database files exist in either location (fresh install)
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Nothing should happen, no files should be created
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should still not exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should still not exist")
}
func testDbMigrateIfNeeded_replacesExistingPrivateFilesWithNewerLegacyFiles() throws {
// Given: Both locations have database files, but legacy has newer files
let privateModificationDate = Date(timeIntervalSinceNow: -3600) // 1 hour ago (older)
let legacyModificationDate = Date() // Now (newer)
let oldPrivateContent = "Old private database content"
let newLegacyContent = "New legacy database content"
try createMockDatabaseFiles(at: privatePath, content: oldPrivateContent, modificationDate: privateModificationDate)
try createMockDatabaseFiles(at: legacyPath, content: newLegacyContent, modificationDate: legacyModificationDate)
// Verify initial state
let initialPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(initialPrivateContent, oldPrivateContent, "Private should have old content initially")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Private files should be replaced with legacy content
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist")
let finalPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(finalPrivateContent, newLegacyContent, "Private database should now contain the newer legacy content")
XCTAssertNotEqual(finalPrivateContent, oldPrivateContent, "Old private content should be replaced")
// Legacy files should be gone (moved, not copied)
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration")
}
func testDbMigrateIfNeeded_migratesPartialDatabaseFiles() throws {
// Given: Legacy location has only one database file (data.mdb but no lock.mdb)
let fileManager = FileManager.default
try fileManager.createDirectory(atPath: legacyPath, withIntermediateDirectories: true)
// Create only data.mdb
let partialContent = "Partial database content"
try partialContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: "\(legacyPath!)/data.mdb"))
// Verify initial state - only one file exists
XCTAssertTrue(fileManager.fileExists(atPath: "\(legacyPath!)/data.mdb"), "data.mdb should exist")
XCTAssertFalse(fileManager.fileExists(atPath: "\(legacyPath!)/lock.mdb"), "lock.mdb should not exist")
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: The partial file SHOULD be migrated
XCTAssertTrue(verifyDataDotMdbExists(at: privatePath), "Private database files should exist (partial migration should occur)")
XCTAssertFalse(fileManager.fileExists(atPath: "\(legacyPath!)/data.mdb"), "Legacy data.mdb should not still exist")
}
func testDbMigrateIfNeeded_migratesWhenPrivatePathDoesNotExist() throws {
// Given: Legacy files exist, but private directory doesn't exist yet
let legacyModificationDate = Date()
let legacyContent = "Legacy database content for new migration"
try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate)
let fileManager = FileManager.default
// Verify initial state
XCTAssertTrue(verifyDatabaseFilesExist(at: legacyPath), "Legacy database files should exist")
XCTAssertFalse(fileManager.fileExists(atPath: privatePath), "Private directory should not exist yet")
// When: Migration is triggered
try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath)
// Then: Private directory should be created and files should be migrated
XCTAssertTrue(fileManager.fileExists(atPath: privatePath), "Private directory should now exist")
XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist after migration")
// Verify content was migrated correctly
let privateDataContent = try String(contentsOfFile: "\(privatePath!)/data.mdb")
XCTAssertEqual(privateDataContent, legacyContent, "Migrated database content should match original")
// Legacy files should be gone (moved)
XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration")
}
}