Add sync mechanism to prevent background crashes and fix ndb reopen order
This adds a sync mechanism in Ndb.swift to coordinate certain usage of nostrdb.c calls and the need to close nostrdb due to app lifecycle requirements. Furthermore, it fixes the order of operations when re-opening NostrDB, to avoid race conditions where a query uses an older Ndb generation. This sync mechanism allows multiple queries to happen simultaneously (from the Swift-side), while preventing ndb from simultaneously closing during such usages. It also does that while keeping the Ndb interface sync and nonisolated, which keeps the API easy to use from Swift/SwiftUI and allows for parallel operations to occur. If Swift Actors were to be used (e.g. creating an NdbActor), the Ndb.swift interface would change in such a way that it would propagate the need for several changes throughout the codebase, including loading logic in some ViewModels. Furthermore, it would likely decrease performance by forcing Ndb.swift operations to run sequentially when they could run in parallel. Changelog-Fixed: Fixed crashes that happened when the app went into background mode Closes: https://github.com/damus-io/damus/issues/3245 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -10,6 +10,7 @@ import Foundation
|
||||
import EmojiPicker
|
||||
|
||||
// Generates a test damus state with configurable mock parameters
|
||||
@MainActor
|
||||
func generate_test_damus_state(
|
||||
mock_profile_info: [Pubkey: Profile]?,
|
||||
home: HomeModel? = nil,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// AppLifecycleHandlingTests.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-11-06.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import damus
|
||||
|
||||
|
||||
class AppLifecycleHandlingTests: XCTestCase {
|
||||
|
||||
func getTestNotesJSONL() -> String {
|
||||
// Get the path for the test_notes.jsonl file in the same folder as this test file
|
||||
let testBundle = Bundle(for: type(of: self))
|
||||
let fileURL = testBundle.url(forResource: "test_notes", withExtension: "jsonl")!
|
||||
|
||||
// Load the contents of the file
|
||||
return try! String(contentsOf: fileURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Tests for some race conditions between the app closing down and streams opening throughout the app
|
||||
/// See https://github.com/damus-io/damus/issues/3245 for more context.
|
||||
///
|
||||
/// **Note:** Time delays are intentionally added because we actually want to provoke possible race conditions,
|
||||
/// so using proper waiting mechanisms would defeat the purpose of the test.
|
||||
func testAppLifecycleRaceConditions() async throws {
|
||||
let damusState = await generate_test_damus_state(mock_profile_info: nil)
|
||||
|
||||
let notesJSONL = getTestNotesJSONL()
|
||||
for noteText in notesJSONL.split(separator: "\n") {
|
||||
let _ = damusState.ndb.processEvent("[\"EVENT\",\"subid\",\(String(noteText))]")
|
||||
}
|
||||
|
||||
// Give some time ndb some time to fill up
|
||||
try? await Task.sleep(for: .milliseconds(2000))
|
||||
|
||||
|
||||
|
||||
// Start measuring the time elapsed for debugging
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
func getElapsedTimeMiliseconds() -> String {
|
||||
return "\((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms"
|
||||
}
|
||||
|
||||
|
||||
Task.detached {
|
||||
for i in 0...10000 {
|
||||
try await Task.sleep(for: .milliseconds(Int.random(in: 0...10)))
|
||||
print("APP_LIFECYCLE_TEST \(i): About to close Ndb. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
damusState.ndb.close()
|
||||
print("APP_LIFECYCLE_TEST \(i): Closed Ndb. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
print("APP_LIFECYCLE_TEST \(i): Reopening Ndb. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
_ = damusState.ndb.reopen()
|
||||
print("APP_LIFECYCLE_TEST \(i): Reopened Ndb. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
|
||||
}
|
||||
}
|
||||
for i in 0...10000 {
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(Int.random(in: 0...10)))
|
||||
print("APP_LIFECYCLE_TEST \(i): Starting new query. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
_ = try damusState.ndb.query(filters: [try NdbFilter(from: NostrFilter(kinds: [.text], limit: 1000))], maxResults: 500)
|
||||
}
|
||||
catch {
|
||||
print("APP_LIFECYCLE_TEST \(i): Query error: \(error). Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
}
|
||||
print("APP_LIFECYCLE_TEST \(i): Finished query. Elapsed time: \(getElapsedTimeMiliseconds())")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import XCTest
|
||||
@testable import damus
|
||||
|
||||
|
||||
@MainActor
|
||||
class NostrNetworkManagerTests: XCTestCase {
|
||||
var damusState: DamusState? = nil
|
||||
|
||||
@@ -137,7 +138,7 @@ class NostrNetworkManagerTests: XCTestCase {
|
||||
switch item {
|
||||
case .event(let noteKey):
|
||||
// Lookup the note to verify it exists
|
||||
if let note = ndb.lookup_note_by_key_and_copy(noteKey) {
|
||||
if let note = try? ndb.lookup_note_by_key_and_copy(noteKey) {
|
||||
count += 1
|
||||
receivedIds.insert(note.id)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class ProfilesManagerTests: XCTestCase {
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
// Verify profile is in NDB
|
||||
let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey)
|
||||
let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey)
|
||||
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
|
||||
XCTAssertEqual(cachedProfile?.name, "testuser")
|
||||
|
||||
@@ -109,7 +109,7 @@ class ProfilesManagerTests: XCTestCase {
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
// Verify profile is in NDB
|
||||
let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey)
|
||||
let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey)
|
||||
XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB")
|
||||
|
||||
// Create ProfilesManager
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import XCTest
|
||||
@testable import damus
|
||||
|
||||
@MainActor
|
||||
final class ThreadModelTests: XCTestCase {
|
||||
var damusState: DamusState? = nil
|
||||
|
||||
@@ -39,8 +40,12 @@ final class ThreadModelTests: XCTestCase {
|
||||
|
||||
/// Tests loading up a thread and checking if the repost count loads as expected.
|
||||
func testActionBarModel() async throws {
|
||||
try! await damusState?.nostrNetwork.userRelayList.set(userRelayList: NIP65.RelayList())
|
||||
await damusState?.nostrNetwork.connect()
|
||||
guard let damusState else {
|
||||
XCTFail("DamusState is nil, test is misconfigured")
|
||||
return
|
||||
}
|
||||
try! await damusState.nostrNetwork.userRelayList.set(userRelayList: NIP65.RelayList())
|
||||
await damusState.nostrNetwork.connect()
|
||||
|
||||
let testNoteJson = """
|
||||
{"content":"https://smartflowsocial.s3.us-east-1.amazonaws.com/clients/cm7kdrwdk0000qyu6fwtd96ui/0cab65a9-0142-48e3-abd7-94d20e30d3b2.jpg\n\n","pubkey":"71ecabd8b6b33548e075ff01b31568ffda19d0ac2788067d99328c6de4885975","tags":[["t","meme"],["t","memes"],["t","memestr"],["t","plebchain"]],"created_at":1755694800,"id":"64b26d0a587f5f894470e1e4783756b4d8ba971226de975ee30ac1b69970d5a1","kind":1,"sig":"c000794da8c4f7549b546630b16ed17f6edc0af0269b8c46ce14f5b1937431e7575b78351bc152007ebab5720028e5fe4b738f99e8887f273d35dd2217d1cc3d"}
|
||||
@@ -48,12 +53,12 @@ final class ThreadModelTests: XCTestCase {
|
||||
let testShouldComplete = XCTestExpectation(description: "Test should complete")
|
||||
Task {
|
||||
let note = NostrEvent.owned_from_json(json: testNoteJson)!
|
||||
let threadModel = await ThreadModel(event: note, damus_state: damusState!)
|
||||
await threadModel.subscribe()
|
||||
let actionBarModel = make_actionbar_model(ev: note.id, damus: damusState!)
|
||||
let threadModel = ThreadModel(event: note, damus_state: damusState)
|
||||
threadModel.subscribe()
|
||||
let actionBarModel = make_actionbar_model(ev: note.id, damus: damusState)
|
||||
while true {
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
await actionBarModel.update(damus: damusState!, evid: note.id)
|
||||
await actionBarModel.update(damus: damusState, evid: note.id)
|
||||
if actionBarModel.boosts >= 5 {
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user