This fixes an arithmetic overflow crash on iOS 17 caused by the fallback lock. In iOS 17, Swift Mutexes are not available, so we have a fallback class for NdbUseLock that does not make use of them. This allows some thread safety for iOS 17 users, but unfortunately not as much as iOS 18+ users. This attempts to fix those remaining race conditions and subsequent crashes by using `NSLock` in the fallback class, which is available on iOS 17. Closes: https://github.com/damus-io/damus/issues/3512 Changelog-Fixed: Fixed a crash on iOS 17 that would happen on startup Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
218 lines
10 KiB
Swift
218 lines
10 KiB
Swift
//
|
||
// NdbUseLock.swift
|
||
// damus
|
||
//
|
||
// Created by Daniel D’Aquino on 2025-11-12.
|
||
//
|
||
|
||
import Dispatch
|
||
import Synchronization
|
||
import Foundation
|
||
|
||
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
|
||
/// Lock for protecting access to `ndbUserCount`
|
||
private let ndbUserCountLock = NSLock()
|
||
/// Lock for protecting access to `ndbIsOpen`
|
||
private let ndbIsOpenLock = NSLock()
|
||
private var ndbIsOpen: Bool = false
|
||
/// 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)
|
||
ndbIsOpenLock.lock()
|
||
ndbIsOpen = operation()
|
||
if ndbIsOpen {
|
||
ndbAccessSemaphore.signal()
|
||
}
|
||
ndbIsOpenLock.unlock()
|
||
}
|
||
|
||
/// Marks `ndb` as open to allow other users to use it. Do not call this more than once
|
||
func markNdbOpen() {
|
||
ndbIsOpenLock.lock()
|
||
if !ndbIsOpen {
|
||
ndbIsOpen = true
|
||
ndbAccessSemaphore.signal()
|
||
}
|
||
ndbIsOpenLock.unlock()
|
||
}
|
||
|
||
private func incrementUserCount(maxTimeout: DispatchTimeInterval = .seconds(2)) throws {
|
||
ndbUserCountLock.lock()
|
||
defer { ndbUserCountLock.unlock() }
|
||
|
||
// Signal that ndb cannot close while we have at least one user using ndb
|
||
if ndbUserCount == 0 {
|
||
try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout)
|
||
}
|
||
ndbUserCount += 1
|
||
}
|
||
|
||
private func decrementUserCount() {
|
||
ndbUserCountLock.lock()
|
||
defer { ndbUserCountLock.unlock() }
|
||
|
||
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
|
||
}
|
||
}
|