Files
damus/nostrdb/NdbUseLock.swift
Daniel D’Aquino 20dc672dbf 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>
2025-12-29 11:01:23 -08:00

198 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
}