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>
254 lines
14 KiB
Swift
254 lines
14 KiB
Swift
//
|
|
// 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")
|
|
}
|
|
}
|