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
}
}
}