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:
Daniel D’Aquino
2025-11-12 16:04:47 -08:00
parent 6d9107f662
commit 20dc672dbf
59 changed files with 790 additions and 416 deletions
@@ -125,7 +125,7 @@ struct NotificationFormatter {
let src = zap.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let profile = profiles.lookup(id: pk)
let profile = try? profiles.lookup(id: pk)
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
@@ -58,7 +58,7 @@ class NotificationService: UNNotificationServiceExtension {
}
let sender_profile = {
let profile = state.profiles.lookup(id: nostr_event.pubkey)
let profile = try? state.profiles.lookup(id: nostr_event.pubkey)
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
@@ -185,7 +185,7 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
// gather recipients
if let recipient_note_id = note.direct_replies() {
let replying_to_pk = ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in
let replying_to_pk = try? ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in
switch replying_to_note {
case .none: return nil
case .some(let note): return note.pubkey
@@ -251,7 +251,7 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
}
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let profile = ndb.lookup_profile(pubkey, borrow: { profileRecord in
let profile = try? ndb.lookup_profile(pubkey, borrow: { profileRecord in
switch profileRecord {
case .some(let pr): return pr.profile
case .none: return nil
+14
View File
@@ -1670,6 +1670,10 @@
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; };
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; };
D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */; };
D75154BF2EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; };
D75154C02EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; };
D75154C12EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; };
D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; };
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; };
D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
@@ -1711,6 +1715,7 @@
D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D795356B2EBD28A800AACF98 /* AppLifecycleHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */; };
D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; };
D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; };
D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; };
@@ -2770,6 +2775,7 @@
D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; };
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; };
D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesManagerTests.swift; sourceTree = "<group>"; };
D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbUseLock.swift; sourceTree = "<group>"; };
D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; };
D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = "<group>"; };
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
@@ -2790,6 +2796,7 @@
D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; };
D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = "<group>"; };
D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleHandlingTests.swift; sourceTree = "<group>"; };
D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; };
D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; };
D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; };
@@ -3301,6 +3308,7 @@
4C9054862A6AEB4500811EEC /* nostrdb */ = {
isa = PBXGroup;
children = (
D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */,
D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */,
D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */,
D7F5630F2DEE71BB008509DE /* NdbFilter.swift */,
@@ -5281,6 +5289,7 @@
isa = PBXGroup;
children = (
D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */,
D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */,
D7EBF8BD2E594708004EAE29 /* test_notes.jsonl */,
D7EBF8BA2E5901F7004EAE29 /* NostrNetworkManagerTests.swift */,
D7EBF8BF2E5D39D1004EAE29 /* ThreadModelTests.swift */,
@@ -5743,6 +5752,7 @@
4CC6AA792CAB688500989CEF /* sha256.c in Sources */,
4CC6AA7B2CAB688500989CEF /* likely.c in Sources */,
4CC6AA7F2CAB688500989CEF /* htable.c in Sources */,
D75154C02EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */,
4CC6AA862CAB688500989CEF /* list.c in Sources */,
4CC6AA872CAB688500989CEF /* utf8.c in Sources */,
4CC6AA892CAB688500989CEF /* debug.c in Sources */,
@@ -6266,6 +6276,7 @@
D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */,
B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */,
3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */,
D795356B2EBD28A800AACF98 /* AppLifecycleHandlingTests.swift in Sources */,
D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */,
B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */,
D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */,
@@ -6785,6 +6796,7 @@
82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */,
82D6FC3B2CD99F7900C925F4 /* LongformView.swift in Sources */,
82D6FC3C2CD99F7900C925F4 /* LongformPreview.swift in Sources */,
D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */,
82D6FC3D2CD99F7900C925F4 /* EventShell.swift in Sources */,
82D6FC3E2CD99F7900C925F4 /* MentionView.swift in Sources */,
82D6FC3F2CD99F7900C925F4 /* EventLoaderView.swift in Sources */,
@@ -7210,6 +7222,7 @@
D73E5F302C6A97F4007EB227 /* EventProfile.swift in Sources */,
D73E5F312C6A97F4007EB227 /* EventMenu.swift in Sources */,
D73E5F322C6A97F4007EB227 /* EventMutingContainerView.swift in Sources */,
D75154C12EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */,
D73E5F332C6A97F4007EB227 /* ZapEvent.swift in Sources */,
5C8F97362EB46145009399B1 /* LiveStreamView.swift in Sources */,
D73E5F342C6A97F4007EB227 /* TextEvent.swift in Sources */,
@@ -7474,6 +7487,7 @@
4CC6AAC52CAB688500989CEF /* likely.c in Sources */,
4CC6AAC92CAB688500989CEF /* htable.c in Sources */,
4CC6AAD02CAB688500989CEF /* list.c in Sources */,
D75154BF2EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */,
4CC6AAD12CAB688500989CEF /* utf8.c in Sources */,
4CC6AAD32CAB688500989CEF /* debug.c in Sources */,
4CC6AAD42CAB688500989CEF /* str.c in Sources */,
+4 -4
View File
@@ -397,7 +397,7 @@ struct ContentView: View {
guard let ds = self.damus_state,
let lud16 = nwc.lud16,
let keypair = ds.keypair.to_full(),
let profile = ds.profiles.lookup(id: ds.pubkey),
let profile = try? ds.profiles.lookup(id: ds.pubkey),
lud16 != profile.lud16 else {
return
}
@@ -560,7 +560,7 @@ struct ContentView: View {
home.filter_events()
guard let ds = damus_state,
let profile = ds.profiles.lookup(id: ds.pubkey),
let profile = try? ds.profiles.lookup(id: ds.pubkey),
let keypair = ds.keypair.to_full()
else {
return
@@ -578,7 +578,7 @@ struct ContentView: View {
}
}, message: {
if case let .user(pubkey, _) = self.muting {
let profile = damus_state!.profiles.lookup(id: pubkey)
let profile = try? damus_state!.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
} else {
@@ -644,7 +644,7 @@ struct ContentView: View {
}
}, message: {
if case let .user(pubkey, _) = muting {
let profile = damus_state?.profiles.lookup(id: pubkey)
let profile = try? damus_state?.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
} else {
@@ -92,11 +92,11 @@ extension NostrNetworkManager {
private func publishProfileUpdates(metadataEvent: borrowing UnownedNdbNote) {
let now = UInt64(Date.now.timeIntervalSince1970)
ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now)
try? ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now)
if let relevantStreams = streams[metadataEvent.pubkey] {
// If we have the user metadata event in ndb, then we should have the profile record as well.
guard let profile = ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return }
guard let profile = try? ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
@@ -107,7 +107,7 @@ extension NostrNetworkManager {
/// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates)
func notifyProfileUpdate(pubkey: Pubkey) {
if let relevantStreams = streams[pubkey] {
guard let profile = ndb.lookup_profile_and_copy(pubkey) else { return }
guard let profile = try? ndb.lookup_profile_and_copy(pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
@@ -141,7 +141,7 @@ extension NostrNetworkManager {
// Yield cached profile immediately so views don't flash placeholder content.
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
if yieldCached, let existingProfile = ndb.lookup_profile_and_copy(pubkey) {
if yieldCached, let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
continuation.yield(existingProfile)
}
@@ -176,7 +176,7 @@ extension NostrNetworkManager {
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
if yieldCached {
for pubkey in pubkeys {
if let existingProfile = ndb.lookup_profile_and_copy(pubkey) {
if let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
continuation.yield(existingProfile)
}
}
@@ -360,7 +360,7 @@ extension NostrNetworkManager {
let filter = NostrFilter(ids: [noteId], limit: 1)
// Since note ids point to immutable objects, we can do a simple ndb lookup first
if let noteKey = self.ndb.lookup_note_key(noteId) {
if let noteKey = try? self.ndb.lookup_note_key(noteId) {
return NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
}
@@ -413,18 +413,18 @@ extension NostrNetworkManager {
switch query {
case .profile(let pubkey):
let profileNotNil = self.ndb.lookup_profile(pubkey, borrow: { pr in
let profileNotNil = try? self.ndb.lookup_profile(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.profile != nil
case .none: return true
}
})
if profileNotNil {
if profileNotNil ?? false {
return .profile(pubkey)
}
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
case .event(let evid):
if let event = self.ndb.lookup_note_and_copy(evid) {
if let event = try? self.ndb.lookup_note_and_copy(evid) {
return .event(event)
}
filter = NostrFilter(ids: [evid], limit: 1)
@@ -87,7 +87,7 @@ extension NostrNetworkManager {
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
return delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
return try? delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
}
/// Gets the latest `kind:3` relay list from NostrDB.
+12 -12
View File
@@ -74,12 +74,12 @@ class Profiles {
profile_data(pubkey).zapper
}
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try ndb.lookup_profile(pubkey, borrow: lendingFunction)
}
func lookup_lnurl(_ pubkey: Pubkey) -> String? {
return lookup_with_timestamp(pubkey, borrow: { pr in
func lookup_lnurl(_ pubkey: Pubkey) throws -> String? {
return try lookup_with_timestamp(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.lnurl
case .none: return nil
@@ -87,16 +87,16 @@ class Profiles {
})
}
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction)
}
func search(_ query: String, limit: Int) -> [Pubkey] {
ndb.search_profile(query, limit: limit)
func search(_ query: String, limit: Int) throws -> [Pubkey] {
try ndb.search_profile(query, limit: limit)
}
func lookup(id: Pubkey) -> Profile? {
return ndb.lookup_profile(id, borrow: { pr in
func lookup(id: Pubkey) throws -> Profile? {
return try ndb.lookup_profile(id, borrow: { pr in
switch pr {
case .none:
return nil
@@ -107,12 +107,12 @@ class Profiles {
})
}
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
ndb.lookup_profile_key(pubkey)
func lookup_key_by_pubkey(_ pubkey: Pubkey) throws -> ProfileKey? {
try ndb.lookup_profile_key(pubkey)
}
func has_fresh_profile(id: Pubkey) -> Bool {
guard let fetched_at = ndb.read_profile_last_fetched(pubkey: id)
func has_fresh_profile(id: Pubkey) throws -> Bool {
guard let fetched_at = try ndb.read_profile_last_fetched(pubkey: id)
else {
return false
}
@@ -41,7 +41,7 @@ struct EventActionBar: View {
// Fetching an LNURL is expensive enough that it can cause a hitch. Use a special backgroundable function to fetch the value.
// Fetch on `.onAppear`
nonisolated func fetchLNURL() {
let lnurl = damus_state.profiles.lookup_lnurl(event.pubkey)
let lnurl = try? damus_state.profiles.lookup_lnurl(event.pubkey)
DispatchQueue.main.async {
self.lnurl = lnurl
}
+1 -1
View File
@@ -115,7 +115,7 @@ struct ChatEventView: View {
// MARK: Zapping properties
var lnurl: String? {
damus_state.profiles.lookup_lnurl(event.pubkey)
try? damus_state.profiles.lookup_lnurl(event.pubkey)
}
var zap_target: ZapTarget {
ZapTarget.note(id: event.id, author: event.pubkey)
@@ -39,7 +39,7 @@ func reply_desc(ndb: Ndb, event: NostrEvent, replying_to: NostrEvent?, locale: L
}
let names: [String] = pubkeys.map { pk in
let profile = ndb.lookup_profile_and_copy(pk)
let profile = try? ndb.lookup_profile_and_copy(pk)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
@@ -328,7 +328,7 @@ func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage)
}
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
let profile = profiles.lookup(id: pk)
let profile = try? profiles.lookup(id: pk)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
@@ -157,7 +157,7 @@ struct FollowPackPreviewBody: View {
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile = state.profiles.lookup(id: event.event.pubkey)
let profile = try? state.profiles.lookup(id: event.event.pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
@@ -135,7 +135,7 @@ struct FollowPackView: View {
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile = state.profiles.lookup(id: event.event.pubkey)
let profile = try? state.profiles.lookup(id: event.event.pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
@@ -26,7 +26,7 @@ class FollowingModel {
var f = NostrFilter(kinds: [.metadata])
f.authors = self.contacts.reduce(into: Array<Pubkey>()) { acc, pk in
// don't fetch profiles we already have
if damus_state.profiles.has_fresh_profile(id: pk) {
if (try? damus_state.profiles.has_fresh_profile(id: pk)) ?? false {
return
}
acc.append(pk)
@@ -101,7 +101,7 @@ struct HighlightEvent {
}
let names: [String] = pubkeys.map { pk in
let profile = ndb.lookup_profile_and_copy(pk)
let profile = try? ndb.lookup_profile_and_copy(pk)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
@@ -63,7 +63,7 @@ struct HighlightEventRef: View {
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
let profile = damus_state.profiles.lookup(id: longform_event.event.pubkey)
let profile = try? damus_state.profiles.lookup(id: longform_event.event.pubkey)
if let display_name = profile?.display_name {
Text(display_name)
@@ -18,7 +18,7 @@ struct LiveStreamProfile: View {
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
let profile = state.profiles.lookup(id: pubkey)
let profile = try? state.profiles.lookup(id: pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: pubkey)
switch displayName {
case .one(let one):
@@ -47,7 +47,7 @@ class NIP05DomainEventsModel: ObservableObject {
var authors = Set<Pubkey>()
for pubkey in state.contacts.get_friend_of_friends_list() {
guard let profile = state.profiles.lookup(id: pubkey),
guard let profile = try? state.profiles.lookup(id: pubkey),
let nip05_str = profile.nip05,
let nip05 = NIP05.parse(nip05_str),
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
@@ -82,7 +82,7 @@ struct NIP05DomainTimelineHeaderView: View {
func friendsOfFriendsString(_ friendsOfFriends: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String {
let bundle = bundleForLocale(locale: locale)
let names: [String] = friendsOfFriends.prefix(3).map { pk in
let profile = ndb.lookup_profile(pk, borrow: { pr in
let profile = try? ndb.lookup_profile(pk, borrow: { pr in
switch pr {
case .some(let pr): return pr.profile
case .none: return nil
@@ -83,8 +83,8 @@ func generate_text_mention_notification(ndb: Ndb, from ev: NostrEvent, state: He
return notification
}
if ev.referenced_ids.contains(where: { note_id in
guard let note_author: Pubkey = state.ndb.lookup_note(note_id, borrow: { note in
if let contains = try? ev.referenced_ids.contains(where: { note_id in
guard let note_author: Pubkey = try state.ndb.lookup_note(note_id, borrow: { note in
switch note {
case .some(let note): return note.pubkey
case .none: return nil
@@ -92,7 +92,7 @@ func generate_text_mention_notification(ndb: Ndb, from ev: NostrEvent, state: He
}) else { return false }
guard note_author == state.keypair.pubkey else { return false }
return true
}) {
}), contains {
// This is a reply to one of our posts
let content_preview = render_notification_content_preview(ndb: state.ndb, ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
@@ -126,7 +126,7 @@ func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, state: He
let content_preview = render_notification_content_preview(ndb: ndb, ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
return state.ndb.lookup_note(evid, borrow: { liked_event in
return try? state.ndb.lookup_note(evid, borrow: { liked_event in
switch liked_event {
case .none:
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
@@ -191,7 +191,7 @@ func render_notification_content_preview(ndb: Ndb, ev: NostrEvent, profiles: Pro
}
func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
let profile = profiles.lookup(id: pubkey)
let profile = try? profiles.lookup(id: pubkey)
return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
}
@@ -228,7 +228,7 @@ func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @e
return
}
guard let lnurl = state.profiles.lookup_with_timestamp(ptag, borrow: { record -> String? in
guard let lnurl = try? state.profiles.lookup_with_timestamp(ptag, borrow: { record -> String? in
switch record {
case .none: return nil
case .some(let record): return record.lnurl
@@ -280,7 +280,7 @@ func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
}
// we can't trust the p tag on note zaps because they can be faked
return ndb.lookup_note(etag, borrow: { note in
return try? ndb.lookup_note(etag, borrow: { note in
switch note {
case .none:
// We don't have the event in cache so we can't check the pubkey.
@@ -79,7 +79,7 @@ class SuggestedUsersViewModel: ObservableObject {
/// Gets suggested user information from a provided pubkey
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
if let profile = damus_state.profiles.lookup(id: pubkey),
if let profile = try? damus_state.profiles.lookup(id: pubkey),
let user = SuggestedUser(name: profile.name, about: profile.about, picture: profile.picture, pubkey: pubkey) {
return user
}
@@ -116,7 +116,7 @@ class DraftArtifacts: Equatable {
case .mention(let mention):
if let pubkey = mention.ref.nip19.pubkey() {
// A profile reference, format things properly.
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
let profile_name = DisplayName(profile: profile, pubkey: pubkey).username
guard let url_address = URL(string: block.asString) else {
rich_text_content.append(.init(string: block.asString))
@@ -173,7 +173,7 @@ class Drafts: ObservableObject {
func load(from damus_state: DamusState) {
guard let note_ids = damus_state.settings.draft_event_ids?.compactMap({ NoteId(hex: $0) }) else { return }
for note_id in note_ids {
let note = damus_state.ndb.lookup_note(note_id, borrow: { event in
let note = try? damus_state.ndb.lookup_note(note_id, borrow: { event in
return event?.toOwned()
})
guard let note else { continue }
@@ -234,13 +234,13 @@ class Drafts: ObservableObject {
draft_events.append(wrapped_note)
}
for (replied_to_note_id, reply_artifacts) in self.replies {
guard let replied_to_note = damus_state.ndb.lookup_note_and_copy(replied_to_note_id) else { continue }
guard let replied_to_note = try? damus_state.ndb.lookup_note_and_copy(replied_to_note_id) else { continue }
let nip37_draft = try? await reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
draft_events.append(wrapped_note)
}
for (quoted_note_id, quote_note_artifacts) in self.quotes {
guard let quoted_note = damus_state.ndb.lookup_note_and_copy(quoted_note_id) else { continue }
guard let quoted_note = try? damus_state.ndb.lookup_note_and_copy(quoted_note_id) else { continue }
let nip37_draft = try? await quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
draft_events.append(wrapped_note)
+1 -1
View File
@@ -212,7 +212,7 @@ struct PostView: View {
return .init(string: "")
}
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
return user_tag_attr_string(profile: profile, pubkey: pubkey)
}
+1 -1
View File
@@ -27,7 +27,7 @@ struct ReplyView: View {
let names = references
.map { pubkey in
let pk = pubkey
let prof = damus.profiles.lookup(id: pk)
let prof = try? damus.profiles.lookup(id: pk)
return "@" + Profile.displayName(profile: prof, pubkey: pk).username.truncate(maxLength: 50)
}
.joined(separator: " ")
@@ -21,7 +21,7 @@ struct UserSearch: View {
}
func on_user_tapped(pk: Pubkey) {
let profile = damus_state.profiles.lookup(id: pk)
let profile = try? damus_state.profiles.lookup(id: pk)
let user_tag = user_tag_attr_string(profile: profile, pubkey: pk)
appendUserTag(withTag: user_tag)
@@ -33,7 +33,7 @@ struct EditMetadataView: View {
init(damus_state: DamusState) {
self.damus_state = damus_state
let data = damus_state.profiles.lookup(id: damus_state.pubkey)
let data = try? damus_state.profiles.lookup(id: damus_state.pubkey)
_name = State(initialValue: data?.name ?? "")
_display_name = State(initialValue: data?.display_name ?? "")
@@ -258,7 +258,7 @@ struct EditMetadataView: View {
}
func didChange() -> Bool {
let data = damus_state.profiles.lookup(id: damus_state.pubkey)
let data = try? damus_state.profiles.lookup(id: damus_state.pubkey)
if data?.name ?? "" != name {
return true
@@ -24,7 +24,7 @@ struct EventProfileName: View {
self.damus_state = damus
self.pubkey = pubkey
self.size = size
let donation = damus.profiles.lookup(id: pubkey)?.damus_donation
let donation = try? damus.profiles.lookup(id: pubkey)?.damus_donation
self._donation = State(wrappedValue: donation)
self.purple_account = nil
}
@@ -59,7 +59,7 @@ struct EventProfileName: View {
}
var body: some View {
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
HStack(spacing: 2) {
switch current_display_name(profile) {
case .one(let one):
@@ -33,16 +33,16 @@ struct ProfileActionSheetView: View {
colorScheme == .light ? DamusColors.white : DamusColors.black
}
func profile_data<T>(borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
func profile_data<T>(borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try damus_state.profiles.lookup_with_timestamp(profile.pubkey, borrow: lendingFunction)
}
func get_profile() -> Profile? {
return damus_state.profiles.lookup(id: profile.pubkey)
return try? damus_state.profiles.lookup(id: profile.pubkey)
}
func get_lnurl() -> String? {
return damus_state.profiles.lookup_lnurl(profile.pubkey)
return try? damus_state.profiles.lookup_lnurl(profile.pubkey)
}
func navigate(route: Route) {
@@ -94,7 +94,7 @@ struct ProfileName: View {
}
var body: some View {
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
HStack(spacing: 2) {
Text(verbatim: "\(prefix)\(name_choice(profile: profile))")
@@ -16,7 +16,7 @@ struct ProfileNameView: View {
var body: some View {
Group {
VStack(alignment: .leading) {
let profile = self.damus.profiles.lookup(id: pubkey)
let profile = try? self.damus.profiles.lookup(id: pubkey)
switch Profile.displayName(profile: profile, pubkey: pubkey) {
case .one:
@@ -98,7 +98,7 @@ struct ProfilePicView: View {
}
func get_lnurl() -> String? {
return profiles.lookup_with_timestamp(pubkey, borrow: { pr in
return try? profiles.lookup_with_timestamp(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.lnurl
case .none: return nil
@@ -134,7 +134,7 @@ struct ProfilePicView: View {
}
func get_profile_url(picture: String?, pubkey: Pubkey, profiles: Profiles) -> URL {
let pic = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey)
let pic = picture ?? (try? profiles.lookup(id: pubkey)?.picture) ?? robohash(pubkey)
if let url = URL(string: pic) {
return url
}
@@ -52,7 +52,7 @@ struct EditProfilePictureView: View {
if let profile_url {
return profile_url
} else if let state = damus_state,
let picture = state.profiles.lookup(id: pubkey)?.picture {
let picture = try? state.profiles.lookup(id: pubkey)?.picture {
return URL(string: picture)
} else {
return profile_url ?? URL(string: robohash(pubkey))
@@ -27,7 +27,7 @@ func follow_btn_txt(_ fs: FollowState, follows_you: Bool) -> String {
func followedByString(_ friend_intersection: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String {
let bundle = bundleForLocale(locale: locale)
let names: [String] = friend_intersection.prefix(3).map { pk in
let profile = ndb.lookup_profile_and_copy(pk)
let profile = try? ndb.lookup_profile_and_copy(pk)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20)
}
@@ -108,7 +108,7 @@ struct ProfileView: View {
}
func getProfileInfo() -> (String, String) {
let ndbprofile = self.damus_state.profiles.lookup(id: profile.pubkey)
let ndbprofile = try? self.damus_state.profiles.lookup(id: profile.pubkey)
let displayName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).displayName.truncate(maxLength: 25)
let userName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).username.truncate(maxLength: 25)
return (displayName, "@\(userName)")
@@ -358,8 +358,8 @@ struct ProfileView: View {
var aboutSection: some View {
VStack(alignment: .leading, spacing: 8.0) {
let lnurl = damus_state.profiles.lookup_lnurl(profile.pubkey)
let ndbprofile = damus_state.profiles.lookup(id: profile.pubkey)
let lnurl = try? damus_state.profiles.lookup_lnurl(profile.pubkey)
let ndbprofile = try? damus_state.profiles.lookup(id: profile.pubkey)
nameSection(ndbprofile: ndbprofile, lnurl: lnurl)
@@ -570,7 +570,7 @@ extension View {
@MainActor
func check_nip05_validity(pubkey: Pubkey, damus_state: DamusState) {
let profiles = damus_state.profiles
let profile = profiles.lookup(id: pubkey)
let profile = try? profiles.lookup(id: pubkey)
guard let nip05 = profile?.nip05,
profiles.is_validated(pubkey) == nil
@@ -121,7 +121,7 @@ struct DamusPurpleAccountView: View {
}
func profile_display_name() -> String {
let profile = damus_state.profiles.lookup(id: account.pubkey)
let profile = try? damus_state.profiles.lookup(id: account.pubkey)
let display_name = DisplayName(profile: profile, pubkey: account.pubkey).displayName
return display_name
}
@@ -120,7 +120,10 @@ func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: Even
}
func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [Pubkey]) -> [Pubkey] {
Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk) }))
Array(Set(pks.filter { pk in
let has_fresh_profile = (try? profiles.has_fresh_profile(id: pk)) ?? false
return !has_fresh_profile
}))
}
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [Pubkey] {
@@ -128,11 +131,14 @@ func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent]
for ev in events {
// lookup profiles from boosted events
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey) {
if ev.known_kind == .boost,
let bev = ev.get_inner_event(cache: cache),
let has_fresh_profiles = try? profiles.has_fresh_profile(id: bev.pubkey),
!has_fresh_profiles {
pubkeys.insert(bev.pubkey)
}
if !profiles.has_fresh_profile(id: ev.pubkey) {
if let has_fresh_profiles = try? profiles.has_fresh_profile(id: ev.pubkey), !has_fresh_profiles {
pubkeys.insert(ev.pubkey)
}
}
@@ -19,7 +19,7 @@ struct PullDownSearchView: View {
func do_search(query: String) {
let limit = 128
let note_keys = state.ndb.text_search(query: query, limit: limit, order: .newest_first)
let note_keys = (try? state.ndb.text_search(query: query, limit: limit, order: .newest_first)) ?? []
var res = [NostrEvent]()
// TODO: fix duplicate results from search
var keyset = Set<NoteKey>()
@@ -32,7 +32,7 @@ struct PullDownSearchView: View {
do {
for note_key in note_keys {
state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in
try? state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in
switch maybeUnownedNote {
case .none: return // Skip this
case .some(let unownedNote):
@@ -142,7 +142,7 @@ struct SearchResultsView: View {
func do_search(query: String) {
let limit = 128
var note_keys = damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)
var note_keys = (try? damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)) ?? []
var res = [NostrEvent]()
// TODO: fix duplicate results from search
var keyset = Set<NoteKey>()
@@ -155,7 +155,7 @@ struct SearchResultsView: View {
do {
for note_key in note_keys {
damus_state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in
try? damus_state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in
switch maybeUnownedNote {
case .none: return
case .some(let unownedNote):
@@ -270,7 +270,7 @@ func make_hashtagable(_ str: String) -> String {
func search_profiles(profiles: Profiles, contacts: Contacts, search: String) -> [Pubkey] {
// Search by hex pubkey.
if let pubkey = hex_decode_pubkey(search),
profiles.lookup_key_by_pubkey(pubkey) != nil
(try? profiles.lookup_key_by_pubkey(pubkey)) != nil
{
return [pubkey]
}
@@ -279,12 +279,12 @@ func search_profiles(profiles: Profiles, contacts: Contacts, search: String) ->
if search.starts(with: "npub"),
let bech32_key = decode_bech32_key(search),
case Bech32Key.pub(let pk) = bech32_key,
profiles.lookup_key_by_pubkey(pk) != nil
(try? profiles.lookup_key_by_pubkey(pk)) != nil
{
return [pk]
}
return profiles.search(search, limit: 128).sorted { a, b in
return (try? profiles.search(search, limit: 128).sorted { a, b in
let aFriendTypePriority = get_friend_type(contacts: contacts, pubkey: a)?.priority ?? 0
let bFriendTypePriority = get_friend_type(contacts: contacts, pubkey: b)?.priority ?? 0
@@ -294,5 +294,5 @@ func search_profiles(profiles: Profiles, contacts: Contacts, search: String) ->
} else {
return false
}
}
}) ?? []
}
@@ -47,7 +47,7 @@ struct SearchingEventView: View {
switch search {
case .nip05(let nip05):
if let pk = state.profiles.nip05_pubkey[nip05] {
if state.profiles.lookup_key_by_pubkey(pk) != nil {
if (try? state.profiles.lookup_key_by_pubkey(pk)) != nil {
self.search_state = .found_profile(pk)
}
} else {
@@ -124,7 +124,7 @@ class HomeModel: ContactsDelegate, ObservableObject {
damus_state.contacts.delegate = self
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note_and_copy(latest_contact_event_id) else { return }
guard let latest_contact_event: NdbNote = try? damus_state.ndb.lookup_note_and_copy(latest_contact_event_id) else { return }
process_contact_event(state: damus_state, ev: latest_contact_event)
}
@@ -153,7 +153,7 @@ class HomeModel: ContactsDelegate, ObservableObject {
var candidates: [NostrEvent] = []
for key in note_keys {
guard let note = damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue }
guard let note = try? damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue }
candidates.append(note)
}
return candidates.max(by: { $0.created_at < $1.created_at })
@@ -166,7 +166,7 @@ class HomeModel: ContactsDelegate, ObservableObject {
var candidates: [NostrEvent] = []
for key in note_keys {
guard let note = damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue }
guard let note = try? damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue }
if note.referenced_params.contains(where: { $0.param.matches_str("mute") }) {
candidates.append(note)
}
@@ -136,7 +136,7 @@ struct SideMenuView: View {
var display_name: String? = nil
do {
let profile = damus_state.profiles.lookup(id: damus_state.pubkey)
let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey)
name = profile?.name
display_name = profile?.display_name
}
@@ -244,7 +244,7 @@ struct NWCSettings: View {
}
}
.onChange(of: model.settings.donation_percent) { p in
guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else {
guard let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey) else {
return
}
@@ -257,7 +257,7 @@ struct NWCSettings: View {
.onDisappear {
guard let keypair = damus_state.keypair.to_full(),
let profile = damus_state.profiles.lookup(id: damus_state.pubkey),
let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey),
model.initial_percent != profile.damus_donation
else {
return
@@ -104,7 +104,7 @@ struct TransactionView: View {
return NSLocalizedString("Unknown", comment: "A name label for an unknown user")
}
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
return Profile.displayName(profile: profile, pubkey: pubkey).displayName
}
@@ -33,8 +33,8 @@ struct ProfileZapLinkView<Content: View>: View {
self.label = label
self.action = action
let profile = damus_state.profiles.lookup(id: pubkey)
let lnurl = damus_state.profiles.lookup_with_timestamp(pubkey, borrow: { pr -> String? in
let profile = try? damus_state.profiles.lookup(id: pubkey)
let lnurl = try? damus_state.profiles.lookup_with_timestamp(pubkey, borrow: { pr -> String? in
switch pr {
case .some(let pr): return pr.lnurl
case .none: return nil
@@ -97,7 +97,7 @@ func zap_type_desc(type: ZapType, profiles: Profiles, pubkey: Pubkey) -> String
case .anon:
return NSLocalizedString("No one will see that you zapped", comment: "Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.")
case .priv:
let prof = profiles.lookup(id: pubkey)
let prof = try? profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: prof, pubkey: pubkey).username.truncate(maxLength: 50)
return String.localizedStringWithFormat(NSLocalizedString("private_zap_description", value: "Only '%@' will see that you zapped them", comment: "Description of private zap type where the zap is sent privately and does not identify the user to the public."), name)
case .non_zap:
+1 -1
View File
@@ -60,7 +60,7 @@ struct NIP05Badge: View {
}
var username_matches_nip05: Bool {
guard let name = damus_state.profiles.lookup(id: pubkey)?.name
guard let name = try? damus_state.profiles.lookup(id: pubkey)?.name
else {
return false
}
+1 -1
View File
@@ -73,7 +73,7 @@ struct QRCodeView: View {
var QRView: some View {
VStack(alignment: .center) {
let profile = damus_state.profiles.lookup(id: pubkey)
let profile = try? damus_state.profiles.lookup(id: pubkey)
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
.padding(.top, 20)
@@ -115,7 +115,7 @@ struct BannerImageView: View {
}
func get_banner_url(banner: String?, pubkey: Pubkey, profiles: Profiles) -> URL? {
let bannerUrlString = banner ?? profiles.lookup(id: pubkey)?.banner ?? ""
let bannerUrlString = banner ?? (try? profiles.lookup(id: pubkey)?.banner) ?? ""
if let url = URL(string: bannerUrlString) {
return url
}
+1 -1
View File
@@ -222,7 +222,7 @@ class EventCache {
return ev
}
if let ev = self.ndb.lookup_note_and_copy(evid) {
if let ev = try? self.ndb.lookup_note_and_copy(evid) {
events[ev.id] = ev
return ev
}
+1
View File
@@ -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 DAquino 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
}
+92 -30
View File
@@ -7,6 +7,7 @@
import Foundation
import OSLog
import Synchronization
fileprivate let APPLICATION_GROUP_IDENTIFIER = "group.com.damus"
@@ -34,6 +35,7 @@ class Ndb {
var generation: Int
private var closed: Bool
private var callbackHandler: Ndb.CallbackHandler
private let ndbAccessLock: Ndb.UseLockProtocol = initLock()
private static let DEFAULT_WRITER_SCRATCH_SIZE: Int32 = 2097152; // 2mb scratch size for the writer thread, it should match with the one specified in nostrdb.c
@@ -158,6 +160,7 @@ class Ndb {
self.ndb = db
self.closed = false
self.callbackHandler = callbackHandler
self.ndbAccessLock.markNdbOpen()
}
private static func migrate_db_location_if_needed() throws {
@@ -216,10 +219,13 @@ class Ndb {
func close() {
guard !self.is_closed else { return }
self.closed = true
try! self.ndbAccessLock.waitUntilNdbCanClose(thenClose: {
print("txn: CLOSING NOSTRDB")
ndb_destroy(self.ndb.ndb)
self.generation += 1
print("txn: NOSTRDB CLOSED")
return false
}, maxTimeout: .milliseconds(2000))
}
func reopen() -> Bool {
@@ -230,11 +236,28 @@ class Ndb {
print("txn: NOSTRDB REOPENED (gen \(generation))")
self.ndb = db // Set the new DB before marking it as open to prevent access to the old DB
self.closed = false
self.ndb = db
self.ndbAccessLock.markNdbOpen()
return true
}
// MARK: Thread safety mechanisms
// Use these for all externally accessible methods that interact with the nostrdb database to prevent race conditions with app lifecycle events (i.e. NostrDB opening and closing)
internal func withNdb<T>(_ useFunction: () throws -> T, maxWaitTimeout: DispatchTimeInterval = .milliseconds(500)) throws -> T {
guard !self.is_closed else { throw NdbStreamError.ndbClosed }
return try self.ndbAccessLock.keepNdbOpen(during: {
// Double-check things to avoid TOCTOU race conditions
guard !self.is_closed else { throw NdbStreamError.ndbClosed }
return try useFunction()
}, maxWaitTimeout: maxWaitTimeout)
}
// MARK: Lookup and query functions
// GH_3245 TODO: This is a low level call, make it hidden from outside Ndb
internal func lookup_blocks_by_key_with_txn(_ key: NoteKey, txn: RawNdbTxnAccessible) -> NdbBlockGroup.BlocksMetadata? {
guard let blocks = ndb_get_blocks_by_key(self.ndb.ndb, &txn.txn, key) else {
@@ -244,7 +267,8 @@ class Ndb {
return NdbBlockGroup.BlocksMetadata(ptr: blocks)
}
func lookup_blocks_by_key<T>(_ key: NoteKey, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) rethrows -> T {
func lookup_blocks_by_key<T>(_ key: NoteKey, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) throws -> T {
return try withNdb({
let txn = SafeNdbTxn<NdbBlockGroup.BlocksMetadata?>.new(on: self) { txn in
lookup_blocks_by_key_with_txn(key, txn: txn)
}
@@ -252,6 +276,7 @@ class Ndb {
return try lendingFunction(nil)
}
return try lendingFunction(txn.val)
})
}
private func lookup_note_by_key_with_txn<Y>(_ key: NoteKey, txn: NdbTxn<Y>) -> NdbNote? {
@@ -263,7 +288,8 @@ class Ndb {
return NdbNote(note: ptr, size: size, owned: false, key: key)
}
func text_search(query: String, limit: Int = 128, order: NdbSearchOrder = .newest_first) -> [NoteKey] {
func text_search(query: String, limit: Int = 128, order: NdbSearchOrder = .newest_first) throws -> [NoteKey] {
return try withNdb({
guard let txn = NdbTxn(ndb: self) else { return [] }
var results = ndb_text_search_results()
let res = query.withCString { q in
@@ -414,24 +440,29 @@ class Ndb {
}
return note_ids
})
}
func lookup_note_by_key<T>(_ key: NoteKey, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) rethrows -> T {
func lookup_note_by_key<T>(_ key: NoteKey, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) throws -> T {
return try withNdb({
let txn = NdbTxn(ndb: self) { txn in
lookup_note_by_key_with_txn(key, txn: txn)
}
guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) }
let unownedNote = UnownedNdbNote(rawNote)
return try lendingFunction(.some(unownedNote))
})
}
func lookup_note_by_key_and_copy(_ key: NoteKey) -> NdbNote? {
return lookup_note_by_key(key, borrow: { maybeUnownedNote -> NdbNote? in
func lookup_note_by_key_and_copy(_ key: NoteKey) throws -> NdbNote? {
return try withNdb({
return try lookup_note_by_key(key, borrow: { maybeUnownedNote -> NdbNote? in
switch maybeUnownedNote {
case .none: return nil
case .some(let unownedNote): return unownedNote.toOwned()
}
})
})
}
private func lookup_profile_by_key_inner(_ key: ProfileKey, txn: RawNdbTxnAccessible) -> ProfileRecord? {
@@ -489,19 +520,22 @@ class Ndb {
lookup_profile_by_key_inner(key, txn: txn)
}
func lookup_profile_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
func lookup_profile_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try withNdb({
let txn = SafeNdbTxn<ProfileRecord?>.new(on: self) { txn in
return lookup_profile_by_key_inner(key, txn: txn)
}
guard let txn else { return try lendingFunction(nil) }
return try lendingFunction(txn.val)
})
}
private func lookup_note_with_txn<Y>(id: NoteId, txn: NdbTxn<Y>) -> NdbNote? {
lookup_note_with_txn_inner(id: id, txn: txn)
}
func lookup_profile_key(_ pubkey: Pubkey) -> ProfileKey? {
func lookup_profile_key(_ pubkey: Pubkey) throws -> ProfileKey? {
return try withNdb({
guard let txn = NdbTxn(ndb: self, with: { txn in
lookup_profile_key_with_txn(pubkey, txn: txn)
}) else {
@@ -509,6 +543,7 @@ class Ndb {
}
return txn.value
})
}
private func lookup_profile_key_with_txn<Y>(_ pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileKey? {
@@ -537,7 +572,8 @@ class Ndb {
}
}
func lookup_note_key(_ id: NoteId) -> NoteKey? {
func lookup_note_key(_ id: NoteId) throws -> NoteKey? {
return try withNdb({
guard let txn = NdbTxn(ndb: self, with: { txn in
lookup_note_key_with_txn(id, txn: txn)
}) else {
@@ -545,32 +581,37 @@ class Ndb {
}
return txn.value
})
}
func lookup_note<T>(_ id: NoteId, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) rethrows -> T {
func lookup_note<T>(_ id: NoteId, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) throws -> T {
return try withNdb({
let txn = NdbTxn(ndb: self) { txn in
lookup_note_with_txn_inner(id: id, txn: txn)
}
guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) }
return try lendingFunction(UnownedNdbNote(rawNote))
})
}
func lookup_note_and_copy(_ id: NoteId) -> NdbNote? {
return self.lookup_note(id, borrow: { unownedNote in
func lookup_note_and_copy(_ id: NoteId) throws -> NdbNote? {
return try self.lookup_note(id, borrow: { unownedNote in
return unownedNote?.toOwned()
})
}
func lookup_profile<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T {
func lookup_profile<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try withNdb({
let txn = SafeNdbTxn<ProfileRecord?>.new(on: self) { txn in
lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn)
}
guard let txn else { return try lendingFunction(nil) }
return try lendingFunction(txn.val)
})
}
func lookup_profile_lnurl(_ pubkey: Pubkey) -> String? {
return lookup_profile(pubkey, borrow: { pr in
func lookup_profile_lnurl(_ pubkey: Pubkey) throws -> String? {
return try lookup_profile(pubkey, borrow: { pr in
switch pr {
case .none: return nil
case .some(let pr): return pr.lnurl
@@ -578,8 +619,8 @@ class Ndb {
})
}
func lookup_profile_and_copy(_ pubkey: Pubkey) -> Profile? {
return self.lookup_profile(pubkey, borrow: { pr in
func lookup_profile_and_copy(_ pubkey: Pubkey) throws -> Profile? {
return try self.lookup_profile(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.profile
case .none: return nil
@@ -592,18 +633,20 @@ class Ndb {
}
func process_client_event(_ str: String) -> Bool {
guard !self.is_closed else { return false }
return (try? withNdb({
return str.withCString { cstr in
return ndb_process_client_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0
}
})) ?? false
}
func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) {
guard !closed else { return }
func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) throws {
return try withNdb({
let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in
guard let p = ptr.baseAddress else { return }
ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at)
}
})
}
private func read_profile_last_fetched<Y>(txn: NdbTxn<Y>, pubkey: Pubkey) -> UInt64? {
@@ -619,15 +662,18 @@ class Ndb {
}
}
func read_profile_last_fetched(pubkey: Pubkey) -> UInt64? {
func read_profile_last_fetched(pubkey: Pubkey) throws -> UInt64? {
return try withNdb({
var last_fetched: UInt64? = nil
let _ = NdbTxn(ndb: self) { txn in
last_fetched = read_profile_last_fetched(txn: txn, pubkey: pubkey)
}
return last_fetched
})
}
func process_event(_ str: String, originRelayURL: String? = nil) -> Bool {
let response = try? withNdb({
guard !is_closed else { return false }
guard let originRelayURL else {
return str.withCString { cstr in
@@ -642,18 +688,24 @@ class Ndb {
return ndb_process_event_with(ndb.ndb, cstr, Int32(str.utf8.count), meta) != 0
}
}
})
return response ?? false
}
func process_events(_ str: String) -> Bool {
guard !is_closed else { return false }
let response = try? withNdb({
return str.withCString { cstr in
return ndb_process_events(ndb.ndb, cstr, str.utf8.count) != 0
}
})
return response ?? false
}
func search_profile(_ search: String, limit: Int) -> [Pubkey] {
func search_profile(_ search: String, limit: Int) throws -> [Pubkey] {
return try withNdb({
guard let txn = NdbTxn<()>.init(ndb: self) else { return [] }
return search_profile(search, limit: limit, txn: txn)
})
}
private func search_profile<Y>(_ search: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
@@ -684,9 +736,11 @@ class Ndb {
// MARK: NdbFilter queries and subscriptions
func query(filters: [NdbFilter], maxResults: Int) throws(NdbStreamError) -> [NoteKey] {
func query(filters: [NdbFilter], maxResults: Int) throws -> [NoteKey] {
return try withNdb({
guard let txn = NdbTxn(ndb: self) else { return [] }
return try query(with: txn, filters: filters, maxResults: maxResults)
})
}
/// Safe wrapper around the `ndb_query` C function
@@ -696,8 +750,7 @@ class Ndb {
/// - maxResults: Maximum number of results to return
/// - Returns: Array of note keys matching the filters
/// - Throws: NdbStreamError if the query fails
private func query<Y>(with txn: NdbTxn<Y>, filters: [NdbFilter], maxResults: Int) throws(NdbStreamError) -> [NoteKey] {
guard !self.is_closed else { throw .ndbClosed }
private func query<Y>(with txn: NdbTxn<Y>, filters: [NdbFilter], maxResults: Int) throws -> [NoteKey] {
let filtersPointer = UnsafeMutablePointer<ndb_filter>.allocate(capacity: filters.count)
defer { filtersPointer.deallocate() }
@@ -711,7 +764,6 @@ class Ndb {
let results = UnsafeMutablePointer<ndb_query_result>.allocate(capacity: maxResults)
defer { results.deallocate() }
guard !self.is_closed else { throw .ndbClosed }
guard ndb_query(&txn.txn, filtersPointer, Int32(filters.count), results, Int32(maxResults), count) == 1 else {
throw NdbStreamError.initialQueryFailed
}
@@ -760,7 +812,7 @@ class Ndb {
}
// Set up subscription
subid = ndb_subscribe(self.ndb.ndb, filtersPointer, Int32(filters.count))
guard let subid = try? withNdb({ ndb_subscribe(self.ndb.ndb, filtersPointer, Int32(filters.count)) }) else { return }
// We are setting the continuation after issuing the subscription call.
// This won't cause lost notes because if any notes get issued before registering
@@ -789,6 +841,10 @@ class Ndb {
do { try Task.checkCancellation() } catch { throw NdbStreamError.cancelled }
var noteIds: [NoteKey] = []
let newEventsStream = try withNdb({
// CRITICAL: Create the subscription FIRST before querying to avoid race condition
// This ensures that any events indexed after subscription but before query won't be missed
let newEventsStream = ndbSubscribe(filters: filters)
@@ -797,10 +853,13 @@ class Ndb {
guard let txn = NdbTxn(ndb: self) else { throw NdbStreamError.cannotOpenTransaction }
// Use our safe wrapper instead of direct C function call
let noteIds = try query(with: txn, filters: filters, maxResults: maxSimultaneousResults)
noteIds = try query(with: txn, filters: filters, maxResults: maxSimultaneousResults)
do { try Task.checkCancellation() } catch { throw NdbStreamError.cancelled }
return newEventsStream
})
// Create a cascading stream that combines initial results with new events
return AsyncStream<StreamItem> { continuation in
// Stream all results already present in the database
@@ -861,7 +920,9 @@ class Ndb {
}
func was(noteKey: NoteKey, seenOn relayUrl: String) throws -> Bool {
return try withNdb({
return try self.was(noteKey: noteKey, seenOn: relayUrl, txn: nil)
})
}
/// Determines if a given note was seen on any of the listed relay URLs
@@ -877,7 +938,9 @@ class Ndb {
/// Determines if a given note was seen on any of the listed relay URLs
func was(noteKey: NoteKey, seenOnAnyOf relayUrls: [String]) throws -> Bool {
return try withNdb({
return try self.was(noteKey: noteKey, seenOnAnyOf: relayUrls, txn: nil)
})
}
// MARK: Internal ndb callback interfaces
@@ -1035,4 +1098,3 @@ func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) thro
func remove_file_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "file://", with: "")
}
+2 -2
View File
@@ -478,8 +478,8 @@ extension NdbNote {
return ThreadReply(tags: self.tags)?.reply.note_id
}
func block_offsets<T>(ndb: Ndb, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) rethrows -> T {
guard let key = ndb.lookup_note_key(self.id) else { return try lendingFunction(nil) }
func block_offsets<T>(ndb: Ndb, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) throws -> T {
guard let key = try ndb.lookup_note_key(self.id) else { return try lendingFunction(nil) }
return try ndb.lookup_blocks_by_key(key, borrow: { blocks in
return try lendingFunction(blocks)
+36 -19
View File
@@ -25,6 +25,12 @@ class NdbTxn<T>: RawNdbTxnAccessible {
.init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn")
}
/// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques
private struct R {
let txn: ndb_txn
let generation: Int
}
init?(ndb: Ndb, with: (NdbTxn<T>) -> T = { _ in () }, name: String? = nil) {
guard !ndb.is_closed else { return nil }
self.name = name ?? "txn"
@@ -43,17 +49,18 @@ class NdbTxn<T>: RawNdbTxnAccessible {
let new_ref_count = ref_count + 1
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
} else {
self.txn = ndb_txn()
guard !ndb.is_closed else { return nil }
self.generation = ndb.generation
let result: R? = try? ndb.withNdb({
var txn = ndb_txn()
#if TXNDEBUG
txn_count += 1
#endif
let ok = ndb_begin_query(ndb.ndb.ndb, &self.txn) != 0
if !ok {
return nil
}
self.generation = ndb.generation
let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
guard ok else { return .none }
return .some(R(txn: txn, generation: ndb.generation))
}, maxWaitTimeout: .milliseconds(200))
guard let result else { return nil }
self.txn = result.txn
self.generation = result.generation
Thread.current.threadDictionary["ndb_txn"] = self.txn
Thread.current.threadDictionary["ndb_txn_ref_count"] = 1
Thread.current.threadDictionary["txn_generation"] = ndb.generation
@@ -97,7 +104,9 @@ class NdbTxn<T>: RawNdbTxnAccessible {
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero")
if new_ref_count <= 0 {
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .milliseconds(200))
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn")
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count")
}
@@ -156,10 +165,16 @@ class SafeNdbTxn<T: ~Copyable> {
.init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn")
}
/// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques
private struct R {
let txn: ndb_txn
let generation: Int
}
static func new(on ndb: Ndb, with valueGetter: (PlaceholderNdbTxn) -> T? = { _ in () }, name: String = "txn") -> SafeNdbTxn<T>? {
guard !ndb.is_closed else { return nil }
var generation = ndb.generation
var txn: ndb_txn
let generation: Int
let txn: ndb_txn
let inherited: Bool
if let active_txn = Thread.current.threadDictionary["ndb_txn"] as? ndb_txn,
let txn_generation = Thread.current.threadDictionary["txn_generation"] as? Int,
@@ -174,26 +189,26 @@ class SafeNdbTxn<T: ~Copyable> {
let new_ref_count = ref_count + 1
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
} else {
txn = ndb_txn()
guard !ndb.is_closed else { return nil }
generation = ndb.generation
let result: R? = try? ndb.withNdb({
var txn = ndb_txn()
#if TXNDEBUG
txn_count += 1
#endif
let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
if !ok {
return nil
}
generation = ndb.generation
guard ok else { return .none }
return .some(R(txn: txn, generation: ndb.generation))
}, maxWaitTimeout: .milliseconds(200))
guard let result else { return nil }
txn = result.txn
generation = result.generation
Thread.current.threadDictionary["ndb_txn"] = txn
Thread.current.threadDictionary["ndb_txn_ref_count"] = 1
Thread.current.threadDictionary["txn_generation"] = ndb.generation
inherited = false
}
#if TXNDEBUG
print("txn: open gen\(self.generation) '\(self.name)' \(txn_count)")
print("txn: open gen\(generation) '\(name)' \(txn_count)")
#endif
let moved = false
let placeholderTxn = PlaceholderNdbTxn(txn: txn)
guard let val = valueGetter(placeholderTxn) else { return nil }
return SafeNdbTxn<T>(ndb: ndb, txn: txn, val: val, generation: generation, inherited: inherited, name: name)
@@ -223,7 +238,9 @@ class SafeNdbTxn<T: ~Copyable> {
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero")
if new_ref_count <= 0 {
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .milliseconds(200))
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn")
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count")
}
+197
View File
@@ -0,0 +1,197 @@
//
// NdbUseLock.swift
// damus
//
// Created by Daniel DAquino on 2025-11-12.
//
import Dispatch
import Synchronization
extension Ndb {
/// Creates a `sync` mechanism for coordinating usages of ndb (read or write) with the app's ability to close ndb.
///
/// This prevents race condition between threads reading from `ndb` and the app trying to close `ndb`
///
/// Implementation notes:
/// - This was made as a synchronous mechanism because using `async` solutions (e.g. isolating `Ndb` into an `NdbActor`)
/// creates a necessity to change way too much code around the codebase, the interface becomes more cumbersome and difficult to use,
/// and might create unnecessary async delays (e.g. it would prevent two tasks from reading Ndb data at once)
@available(iOS 18.0, *)
class UseLock: UseLockProtocol {
/// Number of functions using the `ndb` object (for reading or writing data)
private let ndbUserCount = Mutex<UInt>(0)
/// Semaphore for general access to `ndb`. A closing task requires exclusive access. Users of `ndb` (read/write tasks) share the access
private let ndbAccessSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
private let ndbIsOpen = Mutex<Bool>(false)
/// How long a thread can block before throwing an error
private static let DEFAULT_TIMEOUT: DispatchTimeInterval = .milliseconds(500)
/// Keeps the ndb open while performing some specified operation.
///
/// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period.
/// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
///
/// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible!
/// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up.
/// - Returns: The return result for the given operation
func keepNdbOpen<T>(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws -> T {
try self.incrementUserCount(maxTimeout: maxWaitTimeout)
defer { self.decrementUserCount() } // Use defer to guarantee this will always be called no matter the outcome of the function
return try operation()
}
/// Waits for ndb to be able to close, then closes it.
///
/// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end
///
/// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws {
try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout)
ndbIsOpen.withLock { ndbIsOpen in
ndbIsOpen = operation()
if ndbIsOpen {
ndbAccessSemaphore.signal()
}
}
}
func markNdbOpen() {
ndbIsOpen.withLock { ndbIsOpen in
if !ndbIsOpen {
ndbIsOpen = true
ndbAccessSemaphore.signal()
}
}
}
private func incrementUserCount(maxTimeout: DispatchTimeInterval = .seconds(2)) throws {
try ndbUserCount.withLock { currentCount in
// Signal that ndb cannot close while we have at least one user using ndb
if currentCount == 0 {
try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout)
}
currentCount += 1
}
}
private func decrementUserCount() {
ndbUserCount.withLock { currentCount in
currentCount -= 1
// Signal that ndb can close if we have zero users using ndb
if currentCount == 0 {
ndbAccessSemaphore.signal()
}
}
}
enum LockError: Error {
case timeout
}
}
/// A fallback implementation for `UseLock` that works in iOS older than iOS 18, with reduced syncing mechanisms
class FallbackUseLock: UseLockProtocol {
/// Number of functions using the `ndb` object (for reading or writing data)
private var ndbUserCount: UInt = 0
/// Semaphore for general access to `ndb`. A closing task requires exclusive access. Users of `ndb` (read/write tasks) share the access
private let ndbAccessSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
/// How long a thread can block before throwing an error
private static let DEFAULT_TIMEOUT: DispatchTimeInterval = .milliseconds(500)
/// Keeps the ndb open while performing some specified operation.
///
/// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period.
/// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
///
/// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible!
/// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up.
/// - Returns: The return result for the given operation
func keepNdbOpen<T>(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws -> T {
try self.incrementUserCount(maxTimeout: maxWaitTimeout)
defer { self.decrementUserCount() } // Use defer to guarantee this will always be called no matter the outcome of the function
return try operation()
}
/// Waits for ndb to be able to close, then closes it.
///
/// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end
///
/// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws {
try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout)
let ndbIsOpen = operation()
if ndbIsOpen {
ndbAccessSemaphore.signal()
}
}
/// Marks `ndb` as open to allow other users to use it. Do not call this more than once
func markNdbOpen() {
ndbAccessSemaphore.signal()
}
private func incrementUserCount(maxTimeout: DispatchTimeInterval = .seconds(2)) throws {
if ndbUserCount == 0 {
try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout)
}
ndbUserCount += 1
}
private func decrementUserCount() {
ndbUserCount -= 1
// Signal that ndb can close if we have zero users using ndb
if ndbUserCount == 0 {
ndbAccessSemaphore.signal()
}
}
enum LockError: Error {
case timeout
}
}
protocol UseLockProtocol {
/// Keeps the ndb open while performing some specified operation.
///
/// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period.
/// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
///
/// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible!
/// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up.
/// - Returns: The return result for the given operation
func keepNdbOpen<T>(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval) throws -> T
/// Waits for ndb to be able to close, then closes it.
///
/// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end
///
/// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation
func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval) throws
/// Marks `ndb` as open to allow other users to use it. Do not call this more than once
func markNdbOpen()
}
static func initLock() -> UseLockProtocol {
if #available(iOS 18.0, *) {
return UseLock()
} else {
return FallbackUseLock()
}
}
}
fileprivate extension DispatchSemaphore {
func waitOrThrow(timeout: DispatchTime) throws(TimingError) {
let result = self.wait(timeout: timeout)
switch result {
case .success: return
case .timedOut: throw .timeout
}
}
enum TimingError: Error {
case timeout
}
}
+5 -6
View File
@@ -63,15 +63,14 @@ final class NdbTests: XCTestCase {
do {
let ndb = Ndb(path: db_dir)!
let id = NoteId(hex: "d12c17bde3094ad32f4ab862a6cc6f5c289cfe7d5802270bdf34904df585f349")!
guard let txn = NdbTxn(ndb: ndb) else { return XCTAssert(false) }
let note = ndb.lookup_note_and_copy(id)
let note = try? ndb.lookup_note_and_copy(id)
XCTAssertNotNil(note)
guard let note else { return }
let pk = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")!
XCTAssertEqual(note.pubkey, pk)
let profile = ndb.lookup_profile_and_copy(pk)
let lnurl = ndb.lookup_profile_lnurl(pk)
let profile = try? ndb.lookup_profile_and_copy(pk)
let lnurl = try? ndb.lookup_profile_lnurl(pk)
XCTAssertNotNil(profile)
guard let profile else { return }
@@ -91,14 +90,14 @@ final class NdbTests: XCTestCase {
do {
let ndb = Ndb(path: db_dir)!
let note_ids = ndb.text_search(query: "barked")
let note_ids = (try? ndb.text_search(query: "barked")) ?? []
XCTAssertEqual(note_ids.count, 1)
let expected_note_id = NoteId(hex: "b17a540710fe8495b16bfbaf31c6962c4ba8387f3284a7973ad523988095417e")!
guard note_ids.count > 0 else {
XCTFail("Expected at least one note to be found")
return
}
let note_id = ndb.lookup_note_by_key(note_ids[0], borrow: { maybeUnownedNote -> NoteId? in
let note_id = try? ndb.lookup_note_by_key(note_ids[0], borrow: { maybeUnownedNote -> NoteId? in
switch maybeUnownedNote {
case .none: return nil
case .some(let unownedNote): return unownedNote.id