diff --git a/GRDB/Core/DatabasePool.swift b/GRDB/Core/DatabasePool.swift index 1302e08225..cddd0f4674 100644 --- a/GRDB/Core/DatabasePool.swift +++ b/GRDB/Core/DatabasePool.swift @@ -352,7 +352,7 @@ extension DatabasePool: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.") @@ -390,7 +390,9 @@ extension DatabasePool: DatabaseReader { } } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { guard let readerPool else { value(.failure(DatabaseError.connectionIsClosed())) return @@ -435,7 +437,7 @@ extension DatabasePool: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { guard let readerPool else { @@ -469,7 +471,9 @@ extension DatabasePool: DatabaseReader { } } - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { guard let readerPool else { value(.failure(DatabaseError.connectionIsClosed())) return @@ -514,7 +518,9 @@ extension DatabasePool: DatabaseReader { } } - public func spawnConcurrentRead(_ value: @escaping (Result) -> Void) { + public func spawnConcurrentRead( + _ value: @escaping @Sendable (Result) -> Void + ) { asyncConcurrentRead(value) } @@ -555,7 +561,9 @@ extension DatabasePool: DatabaseReader { /// ``` /// /// - parameter value: A function that accesses the database. - public func asyncConcurrentRead(_ value: @escaping (Result) -> Void) { + public func asyncConcurrentRead( + _ value: @escaping @Sendable (Result) -> Void + ) { // Check that we're on the writer queue... writer.execute { db in // ... and that no transaction is opened. @@ -714,7 +722,9 @@ extension DatabasePool: DatabaseReader { /// /// - important: The `completion` argument is executed in a serial /// dispatch queue, so make sure you use the transaction asynchronously. - func asyncWALSnapshotTransaction(_ completion: @escaping (Result) -> Void) { + func asyncWALSnapshotTransaction( + _ completion: @escaping @Sendable (Result) -> Void + ) { guard let readerPool else { completion(.failure(DatabaseError.connectionIsClosed())) return @@ -740,9 +750,8 @@ extension DatabasePool: DatabaseReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { if configuration.readonly { // The easy case: the database does not change return _addReadOnly( @@ -771,9 +780,8 @@ extension DatabasePool: DatabaseReader { private func _addConcurrent( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { assert(!configuration.readonly, "Use _addReadOnly(observation:) instead") assert(!observation.requiresWriteAccess, "Use _addWriteOnly(observation:) instead") let observer = ValueConcurrentObserver( @@ -796,7 +804,7 @@ extension DatabasePool: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func writeWithoutTransaction( + public func writeWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute(updates) @@ -813,7 +821,7 @@ extension DatabasePool: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func barrierWriteWithoutTransaction( + public func barrierWriteWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { let dbAccess = CancellableDatabaseAccess() @@ -833,7 +841,9 @@ extension DatabasePool: DatabaseWriter { } } - public func asyncBarrierWriteWithoutTransaction(_ updates: @escaping (Result) -> Void) { + public func asyncBarrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Result) -> Void + ) { guard let readerPool else { updates(.failure(DatabaseError.connectionIsClosed())) return @@ -887,7 +897,9 @@ extension DatabasePool: DatabaseWriter { try writer.reentrantSync(updates) } - public func asyncWriteWithoutTransaction(_ updates: @escaping (Database) -> Void) { + public func asyncWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) -> Void + ) { writer.async(updates) } } diff --git a/GRDB/Core/DatabaseQueue.swift b/GRDB/Core/DatabaseQueue.swift index eb5a43f197..57e6f88f8e 100644 --- a/GRDB/Core/DatabaseQueue.swift +++ b/GRDB/Core/DatabaseQueue.swift @@ -234,7 +234,7 @@ extension DatabaseQueue: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute { db in @@ -244,7 +244,9 @@ extension DatabaseQueue: DatabaseReader { } } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { writer.async { db in defer { // Ignore error because we can not notify it. @@ -271,13 +273,15 @@ extension DatabaseQueue: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute(value) } - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { writer.async { value(.success($0)) } } @@ -285,7 +289,9 @@ extension DatabaseQueue: DatabaseReader { try writer.reentrantSync(value) } - public func spawnConcurrentRead(_ value: @escaping (Result) -> Void) { + public func spawnConcurrentRead( + _ value: @escaping @Sendable (Result) -> Void + ) { // Check that we're on the writer queue... writer.execute { db in // ... and that no transaction is opened. @@ -315,9 +321,8 @@ extension DatabaseQueue: DatabaseReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { if configuration.readonly { // The easy case: the database does not change return _addReadOnly( @@ -382,7 +387,7 @@ extension DatabaseQueue: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func writeWithoutTransaction( + public func writeWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute(updates) @@ -394,13 +399,15 @@ extension DatabaseQueue: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func barrierWriteWithoutTransaction( + public func barrierWriteWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute(updates) } - public func asyncBarrierWriteWithoutTransaction(_ updates: @escaping (Result) -> Void) { + public func asyncBarrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Result) -> Void + ) { writer.async { updates(.success($0)) } } @@ -446,7 +453,9 @@ extension DatabaseQueue: DatabaseWriter { try writer.reentrantSync(updates) } - public func asyncWriteWithoutTransaction(_ updates: @escaping (Database) -> Void) { + public func asyncWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) -> Void + ) { writer.async(updates) } } diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index 06b5078363..fc59d0981e 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -217,7 +217,7 @@ public protocol DatabaseReader: AnyObject, Sendable { /// database access, or the error thrown by `value`, or /// `CancellationError` if the task is cancelled. @available(iOS 13, macOS 10.15, tvOS 13, *) - func read( + func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T @@ -247,7 +247,9 @@ public protocol DatabaseReader: AnyObject, Sendable { /// - parameter value: A closure which accesses the database. Its argument /// is a `Result` that provides the database connection, or the failure /// that would prevent establishing the read access to the database. - func asyncRead(_ value: @escaping (Result) -> Void) + func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) /// Executes database operations, and returns their result after they have /// finished executing. @@ -322,7 +324,7 @@ public protocol DatabaseReader: AnyObject, Sendable { /// database access, or the error thrown by `value`, or /// `CancellationError` if the task is cancelled. @available(iOS 13, macOS 10.15, tvOS 13, *) - func unsafeRead( + func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T @@ -357,7 +359,9 @@ public protocol DatabaseReader: AnyObject, Sendable { /// - parameter value: A closure which accesses the database. Its argument /// is a `Result` that provides the database connection, or the failure /// that would prevent establishing the read access to the database. - func asyncUnsafeRead(_ value: @escaping (Result) -> Void) + func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) /// Executes database operations, and returns their result after they have /// finished executing. @@ -415,8 +419,8 @@ public protocol DatabaseReader: AnyObject, Sendable { func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable } extension DatabaseReader { @@ -534,9 +538,8 @@ extension DatabaseReader { @available(iOS 13, macOS 10.15, tvOS 13, *) public func readPublisher( receiveOn scheduler: some Combine.Scheduler = DispatchQueue.main, - value: @escaping (Database) throws -> Output) - -> DatabasePublishers.Read - { + value: @escaping @Sendable (Database) throws -> Output + ) -> DatabasePublishers.Read { OnDemandFuture { fulfill in self.asyncRead { dbResult in fulfill(dbResult.flatMap { db in Result { try value(db) } }) @@ -582,9 +585,8 @@ extension DatabaseReader { func _addReadOnly( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { if scheduler.immediateInitialValue() { do { // Perform a reentrant read, in case the observation would be @@ -659,13 +661,15 @@ extension AnyDatabaseReader: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await base.read(value) } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { base.asyncRead(value) } @@ -675,13 +679,15 @@ extension AnyDatabaseReader: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await base.unsafeRead(value) } - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { base.asyncUnsafeRead(value) } @@ -692,9 +698,8 @@ extension AnyDatabaseReader: DatabaseReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { base._add( observation: observation, scheduling: scheduler, @@ -753,7 +758,9 @@ extension DatabaseSnapshotReader { } // There is no such thing as an unsafe access to a snapshot. - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { asyncRead(value) } } diff --git a/GRDB/Core/DatabaseRegionObservation.swift b/GRDB/Core/DatabaseRegionObservation.swift index 877e14feaa..dc4d00f8f6 100644 --- a/GRDB/Core/DatabaseRegionObservation.swift +++ b/GRDB/Core/DatabaseRegionObservation.swift @@ -43,10 +43,10 @@ extension DatabaseRegionObservation { extension DatabaseRegionObservation { /// The state of a started DatabaseRegionObservation - private enum ObservationState { + private enum ObservationState: Sendable { case cancelled case pending - case started(DatabaseRegionObserver) + case started(StrongReference) } /// Starts observing the database. @@ -85,8 +85,8 @@ extension DatabaseRegionObservation { /// - returns: A DatabaseCancellable that can stop the observation. public func start( in writer: some DatabaseWriter, - onError: @escaping (Error) -> Void, - onChange: @escaping (Database) -> Void) + onError: @escaping @Sendable (Error) -> Void, + onChange: @escaping @Sendable (Database) -> Void) -> AnyDatabaseCancellable { let stateMutex = Mutex(ObservationState.pending) @@ -111,7 +111,7 @@ extension DatabaseRegionObservation { // the observer. db.add(transactionObserver: observer, extent: .observerLifetime) - state = .started(observer) + state = .started(StrongReference(observer)) } } catch { onError(error) @@ -149,10 +149,10 @@ extension DatabaseRegionObservation { private class DatabaseRegionObserver: TransactionObserver { let region: DatabaseRegion - let onChange: (Database) -> Void + let onChange: @Sendable (Database) -> Void var isChanged = false - init(region: DatabaseRegion, onChange: @escaping (Database) -> Void) { + init(region: DatabaseRegion, onChange: @escaping @Sendable (Database) -> Void) { self.region = region self.onChange = onChange } @@ -212,9 +212,14 @@ extension DatabasePublishers { } } - private class DatabaseRegionSubscription: Subscription - where Downstream.Failure == Error, Downstream.Input == Database + private class DatabaseRegionSubscription: + Subscription, @unchecked Sendable + where Downstream: Subscriber, + Downstream.Failure == Error, + Downstream.Input == Database { + // @unchecked Sendable because `cancellable` and `state` are + // protected by `lock`. private struct WaitingForDemand { let downstream: Downstream let writer: any DatabaseWriter diff --git a/GRDB/Core/DatabaseSnapshot.swift b/GRDB/Core/DatabaseSnapshot.swift index 3a1fa807f5..9752a90f14 100644 --- a/GRDB/Core/DatabaseSnapshot.swift +++ b/GRDB/Core/DatabaseSnapshot.swift @@ -152,13 +152,15 @@ extension DatabaseSnapshot: DatabaseSnapshotReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await reader.execute(value) } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { reader.async { value(.success($0)) } } @@ -172,13 +174,15 @@ extension DatabaseSnapshot: DatabaseSnapshotReader { // `DatabaseSnapshotReader`, because of // . @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await read(value) } - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { reader.async { value(.success($0)) } } @@ -191,9 +195,8 @@ extension DatabaseSnapshot: DatabaseSnapshotReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { _addReadOnly( observation: observation, scheduling: scheduler, diff --git a/GRDB/Core/DatabaseSnapshotPool.swift b/GRDB/Core/DatabaseSnapshotPool.swift index 3203dc605e..fa88f15343 100644 --- a/GRDB/Core/DatabaseSnapshotPool.swift +++ b/GRDB/Core/DatabaseSnapshotPool.swift @@ -294,7 +294,7 @@ extension DatabaseSnapshotPool: DatabaseSnapshotReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { guard let readerPool else { @@ -327,7 +327,9 @@ extension DatabaseSnapshotPool: DatabaseSnapshotReader { } } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { guard let readerPool else { value(.failure(DatabaseError.connectionIsClosed())) return @@ -352,7 +354,7 @@ extension DatabaseSnapshotPool: DatabaseSnapshotReader { // `DatabaseSnapshotReader`, because of // . @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await read(value) @@ -376,9 +378,8 @@ extension DatabaseSnapshotPool: DatabaseSnapshotReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable where Reducer: ValueReducer - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable where Reducer: ValueReducer { _addReadOnly(observation: observation, scheduling: scheduler, onChange: onChange) } diff --git a/GRDB/Core/DatabaseWriter.swift b/GRDB/Core/DatabaseWriter.swift index 4aafc7fe1c..8f3fcf9407 100644 --- a/GRDB/Core/DatabaseWriter.swift +++ b/GRDB/Core/DatabaseWriter.swift @@ -132,7 +132,7 @@ public protocol DatabaseWriter: DatabaseReader { /// database access, or the error thrown by `updates`, or /// `CancellationError` if the task is cancelled. @available(iOS 13, macOS 10.15, tvOS 13, *) - func writeWithoutTransaction( + func writeWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T @@ -218,9 +218,9 @@ public protocol DatabaseWriter: DatabaseReader { /// database access, or the error thrown by `updates`, or /// `CancellationError` if the task is cancelled. @available(iOS 13, macOS 10.15, tvOS 13, *) - func barrierWriteWithoutTransaction( - _ updates: @escaping @Sendable (Database) throws -> T) - async throws -> T + func barrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T /// Schedules database operations for execution, and returns immediately. /// @@ -261,7 +261,9 @@ public protocol DatabaseWriter: DatabaseReader { /// - parameter updates: A closure which accesses the database. Its argument /// is a `Result` that provides the database connection, or the failure /// that would prevent establishing the barrier access to the database. - func asyncBarrierWriteWithoutTransaction(_ updates: @escaping (Result) -> Void) + func asyncBarrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Result) -> Void + ) /// Schedules database operations for execution, and returns immediately. /// @@ -293,7 +295,9 @@ public protocol DatabaseWriter: DatabaseReader { /// for more information. /// /// - parameter updates: A closure which accesses the database. - func asyncWriteWithoutTransaction(_ updates: @escaping (Database) -> Void) + func asyncWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) -> Void + ) /// Executes database operations, and returns their result after they have /// finished executing. @@ -377,7 +381,9 @@ public protocol DatabaseWriter: DatabaseReader { /// - parameter value: A closure which accesses the database. Its argument /// is a `Result` that provides the database connection, or the failure /// that would prevent establishing the read access to the database. - func spawnConcurrentRead(_ value: @escaping (Result) -> Void) + func spawnConcurrentRead( + _ value: @escaping @Sendable (Result) -> Void + ) } extension DatabaseWriter { @@ -463,9 +469,9 @@ extension DatabaseWriter { /// - parameter updates: A closure which accesses the database. /// - parameter completion: A closure called with the transaction result. public func asyncWrite( - _ updates: @escaping (Database) throws -> T, - completion: @escaping (Database, Result) -> Void) - { + _ updates: @escaping @Sendable (Database) throws -> T, + completion: @escaping @Sendable (Database, Result) -> Void + ) { asyncWriteWithoutTransaction { db in do { var result: T? @@ -594,9 +600,8 @@ extension DatabaseWriter { func _addWriteOnly( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { assert(!configuration.readonly, "Use _addReadOnly(observation:) instead") let observer = ValueWriteOnlyObserver( writer: self, @@ -645,7 +650,7 @@ extension DatabaseWriter { /// database access, or the error thrown by `updates`, or /// `CancellationError` if the task is cancelled. @available(iOS 13, macOS 10.15, tvOS 13, *) - public func write( + public func write( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await writeWithoutTransaction { db in @@ -752,9 +757,8 @@ extension DatabaseWriter { @available(iOS 13, macOS 10.15, tvOS 13, *) public func writePublisher( receiveOn scheduler: some Combine.Scheduler = DispatchQueue.main, - updates: @escaping (Database) throws -> Output) - -> DatabasePublishers.Write - { + updates: @escaping @Sendable (Database) throws -> Output + ) -> DatabasePublishers.Write { OnDemandFuture { fulfill in self.asyncWrite(updates, completion: { _, result in fulfill(result) @@ -815,13 +819,11 @@ extension DatabaseWriter { /// - parameter updates: A closure which writes in the database. /// - parameter value: A closure which reads from the database. @available(iOS 13, macOS 10.15, tvOS 13, *) - public func writePublisher( - receiveOn scheduler: S = DispatchQueue.main, - updates: @escaping (Database) throws -> T, - thenRead value: @escaping (Database, T) throws -> Output) - -> DatabasePublishers.Write - where S: Scheduler - { + public func writePublisher( + receiveOn scheduler: some Combine.Scheduler = DispatchQueue.main, + updates: @escaping @Sendable (Database) throws -> T, + thenRead value: @escaping @Sendable (Database, T) throws -> Output + ) -> DatabasePublishers.Write { OnDemandFuture { fulfill in self.asyncWriteWithoutTransaction { db in var updatesValue: T? @@ -834,7 +836,7 @@ extension DatabaseWriter { fulfill(.failure(error)) return } - self.spawnConcurrentRead { dbResult in + self.spawnConcurrentRead { [updatesValue] dbResult in fulfill(dbResult.flatMap { db in Result { try value(db, updatesValue!) } }) } } @@ -910,13 +912,15 @@ extension AnyDatabaseWriter: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func read( + public func read( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await base.read(value) } - public func asyncRead(_ value: @escaping (Result) -> Void) { + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { base.asyncRead(value) } @@ -926,13 +930,15 @@ extension AnyDatabaseWriter: DatabaseReader { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func unsafeRead( + public func unsafeRead( _ value: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await base.unsafeRead(value) } - public func asyncUnsafeRead(_ value: @escaping (Result) -> Void) { + public func asyncUnsafeRead( + _ value: @escaping @Sendable (Result) -> Void + ) { base.asyncUnsafeRead(value) } @@ -943,9 +949,8 @@ extension AnyDatabaseWriter: DatabaseReader { public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping (Reducer.Value) -> Void) - -> AnyDatabaseCancellable - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable { base._add( observation: observation, scheduling: scheduler, @@ -960,7 +965,7 @@ extension AnyDatabaseWriter: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func writeWithoutTransaction( + public func writeWithoutTransaction( _ updates: @escaping @Sendable (Database) throws -> T ) async throws -> T { try await base.writeWithoutTransaction(updates) @@ -972,17 +977,21 @@ extension AnyDatabaseWriter: DatabaseWriter { } @available(iOS 13, macOS 10.15, tvOS 13, *) - public func barrierWriteWithoutTransaction( - _ updates: @escaping @Sendable (Database) throws -> T) - async throws -> T { + public func barrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) throws -> T + ) async throws -> T { try await base.barrierWriteWithoutTransaction(updates) } - public func asyncBarrierWriteWithoutTransaction(_ updates: @escaping (Result) -> Void) { + public func asyncBarrierWriteWithoutTransaction( + _ updates: @escaping @Sendable (Result) -> Void + ) { base.asyncBarrierWriteWithoutTransaction(updates) } - public func asyncWriteWithoutTransaction(_ updates: @escaping (Database) -> Void) { + public func asyncWriteWithoutTransaction( + _ updates: @escaping @Sendable (Database) -> Void + ) { base.asyncWriteWithoutTransaction(updates) } @@ -990,7 +999,9 @@ extension AnyDatabaseWriter: DatabaseWriter { try base.unsafeReentrantWrite(updates) } - public func spawnConcurrentRead(_ value: @escaping (Result) -> Void) { + public func spawnConcurrentRead( + _ value: @escaping @Sendable (Result) -> Void + ) { base.spawnConcurrentRead(value) } } diff --git a/GRDB/Core/SerializedDatabase.swift b/GRDB/Core/SerializedDatabase.swift index b2dccb74a8..031de45927 100644 --- a/GRDB/Core/SerializedDatabase.swift +++ b/GRDB/Core/SerializedDatabase.swift @@ -223,7 +223,7 @@ final class SerializedDatabase { } /// Schedules database operations for execution, and returns immediately. - func async(_ block: @escaping (Database) -> Void) { + func async(_ block: @escaping @Sendable (Database) -> Void) { queue.async { block(self.db) self.preconditionNoUnsafeTransactionLeft(self.db) @@ -245,7 +245,7 @@ final class SerializedDatabase { /// Asynchrously executes the block. @available(iOS 13, macOS 10.15, tvOS 13, *) - func execute( + func execute( _ block: @escaping @Sendable (Database) throws -> T ) async throws -> T { let dbAccess = CancellableDatabaseAccess() diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 99fb4dcfdf..5c7593916c 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -110,14 +110,17 @@ extension Database { /// - parameter onCommit: A closure executed on transaction commit. /// - parameter onRollback: A closure executed on transaction rollback. public func afterNextTransaction( - onCommit: @escaping (Database) -> Void, - onRollback: @escaping (Database) -> Void = { _ in }) + onCommit: @escaping @Sendable (Database) -> Void, + onRollback: @escaping @Sendable (Database) -> Void = { _ in }) { class TransactionHandler: TransactionObserver { - let onCommit: (Database) -> Void - let onRollback: (Database) -> Void + let onCommit: @Sendable (Database) -> Void + let onRollback: @Sendable (Database) -> Void - init(onCommit: @escaping (Database) -> Void, onRollback: @escaping (Database) -> Void) { + init( + onCommit: @escaping @Sendable (Database) -> Void, + onRollback: @escaping @Sendable (Database) -> Void + ) { self.onCommit = onCommit self.onRollback = onRollback } diff --git a/GRDB/Migration/DatabaseMigrator.swift b/GRDB/Migration/DatabaseMigrator.swift index 00c759c220..b67336b799 100644 --- a/GRDB/Migration/DatabaseMigrator.swift +++ b/GRDB/Migration/DatabaseMigrator.swift @@ -264,7 +264,7 @@ public struct DatabaseMigrator: Sendable { /// from succeeding. public func asyncMigrate( _ writer: some DatabaseWriter, - completion: @escaping (Result) -> Void) + completion: @escaping @Sendable (Result) -> Void) { writer.asyncBarrierWriteWithoutTransaction { dbResult in do { @@ -499,7 +499,7 @@ extension DatabaseMigrator { @available(iOS 13, macOS 10.15, tvOS 13, *) public func migratePublisher( _ writer: some DatabaseWriter, - receiveOn scheduler: some Scheduler = DispatchQueue.main) + receiveOn scheduler: some Combine.Scheduler = DispatchQueue.main) -> DatabasePublishers.Migrate { DatabasePublishers.Migrate( diff --git a/GRDB/Utils/Mutex.swift b/GRDB/Utils/Mutex.swift index 1fb69c2e22..771a57a4a6 100644 --- a/GRDB/Utils/Mutex.swift +++ b/GRDB/Utils/Mutex.swift @@ -52,3 +52,32 @@ extension Mutex where T: Numeric { } extension Mutex: @unchecked Sendable where T: Sendable { } + +// MARK: - UnsafeSendableMutex + +/// `UnsafeSendableMutex` is a Mutex that is always Sendable. It is unsafe, +/// because it does not guarantee that its value won't escape from the +/// critical section. +/// +/// We'll replace it with the SE-0433 Mutex when it is available. +/// +/// +/// For a longer discussion about Sendable and mutexes, see +/// +final class UnsafeSendableMutex: @unchecked Sendable { + private var _value: T + private var lock = NSLock() + + init(_ value: sending T) { + _value = value + } + + /// Runs the provided closure while holding a lock on the value. + /// + /// - parameter body: A closure that can modify the value. + func withLock(_ body: (inout T) throws -> U) rethrows -> U { + lock.lock() + defer { lock.unlock() } + return try body(&_value) + } +} diff --git a/GRDB/Utils/OnDemandFuture.swift b/GRDB/Utils/OnDemandFuture.swift index 645da8d199..c18dc54654 100644 --- a/GRDB/Utils/OnDemandFuture.swift +++ b/GRDB/Utils/OnDemandFuture.swift @@ -22,9 +22,9 @@ struct OnDemandFuture: Publisher { typealias Promise = @Sendable (Result) -> Void typealias Output = Output typealias Failure = Failure - fileprivate let attemptToFulfill: (@escaping Promise) -> Void + fileprivate let attemptToFulfill: @Sendable (@escaping Promise) -> Void - init(_ attemptToFulfill: @escaping (@escaping Promise) -> Void) { + init(_ attemptToFulfill: @escaping @Sendable (@escaping Promise) -> Void) { self.attemptToFulfill = attemptToFulfill } @@ -51,7 +51,7 @@ private class OnDemandFutureSubscription: Subscription, private let lock = NSRecursiveLock() // Allow re-entrancy init( - attemptToFulfill: @escaping (@escaping Promise) -> Void, + attemptToFulfill: @escaping @Sendable (@escaping Promise) -> Void, downstream: Downstream) { self.state = .waitingForDemand(downstream: downstream, attemptToFulfill: attemptToFulfill) diff --git a/GRDB/Utils/Pool.swift b/GRDB/Utils/Pool.swift index 0e38d4550b..661c9c4d9d 100644 --- a/GRDB/Utils/Pool.swift +++ b/GRDB/Utils/Pool.swift @@ -121,7 +121,7 @@ final class Pool: Sendable { /// /// - important: The `execute` argument is executed in a serial dispatch /// queue, so make sure you use the element asynchronously. - func asyncGet(_ execute: @escaping (Result) -> Void) { + func asyncGet(_ execute: @escaping @Sendable (Result) -> Void) { // Inspired by https://khanlou.com/2016/04/the-GCD-handbook/ // > We wait on the semaphore in the serial queue, which means that // > we’ll have at most one blocked thread when we reach maximum @@ -186,7 +186,7 @@ final class Pool: Sendable { /// Asynchronously runs the `barrier` function when no element is used, and /// before any other element is dequeued. - func asyncBarrier(execute barrier: @escaping () -> Void) { + func asyncBarrier(execute barrier: @escaping @Sendable () -> Void) { barrierQueue.async(flags: [.barrier]) { self.itemsGroup.wait() barrier() diff --git a/GRDB/Utils/Utils.swift b/GRDB/Utils/Utils.swift index 40095bba8d..561c3b91f1 100644 --- a/GRDB/Utils/Utils.swift +++ b/GRDB/Utils/Utils.swift @@ -118,14 +118,29 @@ func throwingFirstError(execute: () throws -> T, finally: () throws -> Void) return result! } -struct PrintOutputStream: TextOutputStream { +struct PrintOutputStream: TextOutputStream, Sendable { func write(_ string: String) { Swift.print(string) } } +/// A Sendable strong reference to an object. +/// +/// This type hides its retained object in order to provide the +/// Sendable guarantee. +final class StrongReference: @unchecked Sendable { + private let value: Value + + init(_ value: Value) { + self.value = value + } +} + /// Concatenates two functions -func concat(_ rhs: (() -> Void)?, _ lhs: (() -> Void)?) -> (() -> Void)? { +func concat( + _ rhs: (@Sendable () -> Void)?, + _ lhs: (@Sendable () -> Void)? +) -> (@Sendable () -> Void)? { switch (rhs, lhs) { case let (rhs, nil): return rhs @@ -140,7 +155,10 @@ func concat(_ rhs: (() -> Void)?, _ lhs: (() -> Void)?) -> (() -> Void)? { } /// Concatenates two functions -func concat(_ rhs: ((T) -> Void)?, _ lhs: ((T) -> Void)?) -> ((T) -> Void)? { +func concat( + _ rhs: (@Sendable (T) -> Void)?, + _ lhs: (@Sendable (T) -> Void)? +) -> (@Sendable (T) -> Void)? { switch (rhs, lhs) { case let (rhs, nil): return rhs diff --git a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift index 2671b6b095..ad3e0bd188 100644 --- a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift @@ -161,7 +161,7 @@ final class ValueConcurrentObserver Void) + onChange: @escaping @Sendable (Reducer.Value) -> Void) { // Configuration self.scheduler = scheduler diff --git a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift index 3f5a1c50f6..c34e0ff467 100644 --- a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift @@ -150,7 +150,7 @@ final class ValueWriteOnlyObserver< trackingMode: ValueObservationTrackingMode, reducer: Reducer, events: ValueObservationEvents, - onChange: @escaping (Reducer.Value) -> Void) + onChange: @escaping @Sendable (Reducer.Value) -> Void) { // Configuration self.scheduler = scheduler diff --git a/GRDB/ValueObservation/Reducers/Fetch.swift b/GRDB/ValueObservation/Reducers/Fetch.swift index 6ab2f1583b..693c934bbf 100644 --- a/GRDB/ValueObservation/Reducers/Fetch.swift +++ b/GRDB/ValueObservation/Reducers/Fetch.swift @@ -1,6 +1,6 @@ extension ValueReducers { /// A `ValueReducer` that perform database fetches. - public struct Fetch: ValueReducer { + public struct Fetch: ValueReducer { public struct _Fetcher: _ValueReducerFetcher { let _fetch: @Sendable (Database) throws -> Value diff --git a/GRDB/ValueObservation/Reducers/Map.swift b/GRDB/ValueObservation/Reducers/Map.swift index 0b90fda7f8..ba6f1ec674 100644 --- a/GRDB/ValueObservation/Reducers/Map.swift +++ b/GRDB/ValueObservation/Reducers/Map.swift @@ -18,7 +18,7 @@ extension ValueObservation { /// /// - parameter transform: A closure that takes one value as its parameter /// and returns a new value. - public func map(_ transform: @escaping (Reducer.Value) throws -> T) + public func map(_ transform: @escaping @Sendable (Reducer.Value) throws -> T) -> ValueObservation> { mapReducer { ValueReducers.Map($0, transform) } @@ -30,11 +30,11 @@ extension ValueReducers { /// passed through a transform function. /// /// See ``ValueObservation/map(_:)``. - public struct Map: ValueReducer { + public struct Map: ValueReducer { private var base: Base private let transform: (Base.Value) throws -> Value - init(_ base: Base, _ transform: @escaping (Base.Value) throws -> Value) { + init(_ base: Base, _ transform: @escaping @Sendable (Base.Value) throws -> Value) { self.base = base self.transform = transform } diff --git a/GRDB/ValueObservation/Reducers/RemoveDuplicates.swift b/GRDB/ValueObservation/Reducers/RemoveDuplicates.swift index 6320664083..07ec82f593 100644 --- a/GRDB/ValueObservation/Reducers/RemoveDuplicates.swift +++ b/GRDB/ValueObservation/Reducers/RemoveDuplicates.swift @@ -5,10 +5,22 @@ extension ValueObservation { /// - parameter predicate: A closure to evaluate whether two values are /// equivalent, for purposes of filtering. Return true from this closure /// to indicate that the second element is a duplicate of the first. - public func removeDuplicates(by predicate: @escaping (Reducer.Value, Reducer.Value) -> Bool) - -> ValueObservation> - { - mapReducer { ValueReducers.RemoveDuplicates($0, predicate: predicate) } + public func removeDuplicates( + by predicate: sending @escaping (Reducer.Value, Reducer.Value) -> Bool + ) -> ValueObservation> { + // The predicate is marked `sending`, which allows us to statically + // determine that it will have no other uses after this call. + // (according to ) + // + // And because `predicate` will only be used serially, in the + // reducer queue of `ValueObservation` observers, we can say that + // this is safe. + // + // Anyway if we would not accept non-sendable closures, we could + // not deal with `Equatable.==`... + nonisolated(unsafe) let predicate = predicate + + return mapReducer { ValueReducers.RemoveDuplicates($0, predicate: predicate) } } } diff --git a/GRDB/ValueObservation/Reducers/ValueReducer.swift b/GRDB/ValueObservation/Reducers/ValueReducer.swift index 52cf4e848e..8c7386693a 100644 --- a/GRDB/ValueObservation/Reducers/ValueReducer.swift +++ b/GRDB/ValueObservation/Reducers/ValueReducer.swift @@ -16,7 +16,7 @@ public protocol _ValueReducer { associatedtype Fetcher: _ValueReducerFetcher /// The type of observed values - associatedtype Value + associatedtype Value: Sendable /// Returns a value that fetches database values upon changes in an /// observed database region. The returned value method must not depend diff --git a/GRDB/ValueObservation/SharedValueObservation.swift b/GRDB/ValueObservation/SharedValueObservation.swift index e58d01885c..2208ae4d1b 100644 --- a/GRDB/ValueObservation/SharedValueObservation.swift +++ b/GRDB/ValueObservation/SharedValueObservation.swift @@ -167,7 +167,8 @@ extension ValueObservation { /// let cancellable1 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...) /// let cancellable2 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...) /// ``` -public final class SharedValueObservation { +public final class SharedValueObservation: @unchecked Sendable { + // @unchecked Sendable because state is protected by `lock`. private let scheduler: any ValueObservationScheduler private let extent: SharedValueObservationExtent private let startObservation: ValueObservationStart @@ -179,11 +180,14 @@ public final class SharedValueObservation { private var cancellable: AnyDatabaseCancellable? private var lastResult: Result? - private final class Client { - var onError: (Error) -> Void - var onChange: (Element) -> Void + private final class Client: Sendable { + let onError: @Sendable (Error) -> Void + let onChange: @Sendable (Element) -> Void - init(onError: @escaping (Error) -> Void, onChange: @escaping (Element) -> Void) { + init( + onError: @escaping @Sendable (Error) -> Void, + onChange: @escaping @Sendable (Element) -> Void + ) { self.onError = onError self.onChange = onChange } @@ -224,11 +228,11 @@ public final class SharedValueObservation { /// fresh value. /// - returns: A DatabaseCancellable that can stop the observation. public func start( - onError: @escaping (Error) -> Void, - onChange: @escaping (Element) -> Void) + onError: @escaping @Sendable (Error) -> Void, + onChange: @escaping @Sendable (Element) -> Void) -> AnyDatabaseCancellable { - synchronized { + withLock { // Support for reentrancy: a shared immediate observation is // started from the first value notification of that same shared // immediate observation. Yeah, users are nasty. @@ -300,7 +304,7 @@ public final class SharedValueObservation { #endif private func handleError(_ error: Error) { - synchronized { + withLock { let notifiedClients = clients // State change @@ -321,7 +325,7 @@ public final class SharedValueObservation { } private func handleChange(_ value: Element) { - synchronized { + withLock { // State change lastResult = .success(value) @@ -333,7 +337,7 @@ public final class SharedValueObservation { } private func handleCancel(_ client: Client) { - synchronized { + withLock { // State change clients.removeFirst(where: { $0 === client }) if clients.isEmpty && extent == .whileObserved { @@ -344,7 +348,7 @@ public final class SharedValueObservation { } } - private func synchronized(_ execute: () throws -> T) rethrows -> T { + private func withLock(_ execute: () throws -> T) rethrows -> T { lock.lock() defer { lock.unlock() } return try execute() diff --git a/GRDB/ValueObservation/ValueObservation.swift b/GRDB/ValueObservation/ValueObservation.swift index aa8a59f6dd..32abcd97f8 100644 --- a/GRDB/ValueObservation/ValueObservation.swift +++ b/GRDB/ValueObservation/ValueObservation.swift @@ -4,7 +4,7 @@ import Combine import Dispatch import Foundation -public struct ValueObservation { +public struct ValueObservation: Sendable { var events = ValueObservationEvents() /// A boolean value indicating whether the observation requires write access @@ -30,10 +30,10 @@ public struct ValueObservation { /// The reducer is created when observation starts, and is triggered upon /// each database change. - var makeReducer: () -> Reducer + var makeReducer: @Sendable () -> Reducer /// Returns a ValueObservation with a transformed reducer. - func mapReducer(_ transform: @escaping (Reducer) -> R) -> ValueObservation { + func mapReducer(_ transform: @escaping @Sendable (Reducer) -> R) -> ValueObservation { let makeReducer = self.makeReducer return ValueObservation( events: events, @@ -74,16 +74,16 @@ enum ValueObservationTrackingMode { } struct ValueObservationEvents: Refinable { - var willStart: (() -> Void)? - var willTrackRegion: ((DatabaseRegion) -> Void)? - var databaseDidChange: (() -> Void)? - var didFail: ((Error) -> Void)? - var didCancel: (() -> Void)? + var willStart: (@Sendable () -> Void)? + var willTrackRegion: (@Sendable (DatabaseRegion) -> Void)? + var databaseDidChange: (@Sendable () -> Void)? + var didFail: (@Sendable (Error) -> Void)? + var didCancel: (@Sendable () -> Void)? } -typealias ValueObservationStart = ( - _ onError: @escaping (Error) -> Void, - _ onChange: @escaping (T) -> Void) +typealias ValueObservationStart = @Sendable ( + _ onError: @escaping @Sendable (Error) -> Void, + _ onChange: @escaping @Sendable (T) -> Void) -> AnyDatabaseCancellable extension ValueObservation: Refinable { @@ -138,8 +138,8 @@ extension ValueObservation: Refinable { public func start( in reader: some DatabaseReader, scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main), - onError: @escaping (Error) -> Void, - onChange: @escaping (Reducer.Value) -> Void) + onError: @escaping @Sendable (Error) -> Void, + onChange: @escaping @Sendable (Reducer.Value) -> Void) -> AnyDatabaseCancellable where Reducer: ValueReducer { @@ -175,13 +175,13 @@ extension ValueObservation: Refinable { /// - returns: A `ValueObservation` that performs the specified closures /// when ValueObservation events occur. public func handleEvents( - willStart: (() -> Void)? = nil, + willStart: (@Sendable () -> Void)? = nil, willFetch: (@Sendable () -> Void)? = nil, - willTrackRegion: ((DatabaseRegion) -> Void)? = nil, - databaseDidChange: (() -> Void)? = nil, - didReceiveValue: ((Reducer.Value) -> Void)? = nil, - didFail: ((Error) -> Void)? = nil, - didCancel: (() -> Void)? = nil) + willTrackRegion: (@Sendable (DatabaseRegion) -> Void)? = nil, + databaseDidChange: (@Sendable () -> Void)? = nil, + didReceiveValue: (@Sendable (Reducer.Value) -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + didCancel: (@Sendable () -> Void)? = nil) -> ValueObservation> { self @@ -231,10 +231,10 @@ extension ValueObservation: Refinable { /// used to log messages to other destinations. public func print( _ prefix: String = "", - to stream: TextOutputStream? = nil) + to stream: sending TextOutputStream? = nil) -> ValueObservation> { - let streamMutex = Mutex(stream ?? PrintOutputStream()) + let streamMutex = UnsafeSendableMutex(stream ?? PrintOutputStream()) let prefix = prefix.isEmpty ? "" : "\(prefix): " return handleEvents( willStart: { @@ -336,7 +336,7 @@ extension ValueObservation { /// You build an `AsyncValueObservation` from ``ValueObservation`` or /// ``SharedValueObservation``. @available(iOS 13, macOS 10.15, tvOS 13, *) -public struct AsyncValueObservation: AsyncSequence { +public struct AsyncValueObservation: AsyncSequence { public typealias BufferingPolicy = AsyncThrowingStream.Continuation.BufferingPolicy public typealias AsyncIterator = Iterator @@ -476,9 +476,13 @@ extension DatabasePublishers { } } - private class ValueSubscription: Subscription - where Downstream.Failure == Error + private class ValueSubscription: + Subscription, @unchecked Sendable + where Downstream: Subscriber, + Downstream.Failure == Error { + // @unchecked Sendable because `cancellable` and `state` are + // protected by `lock`. private struct WaitingForDemand { let downstream: Downstream let start: ValueObservationStart diff --git a/GRDB/ValueObservation/ValueObservationScheduler.swift b/GRDB/ValueObservation/ValueObservationScheduler.swift index df109cfee0..3e4d9fdce8 100644 --- a/GRDB/ValueObservation/ValueObservationScheduler.swift +++ b/GRDB/ValueObservation/ValueObservationScheduler.swift @@ -17,11 +17,11 @@ public protocol ValueObservationScheduler: Sendable { /// If the result is true, then this method was called on the main thread. func immediateInitialValue() -> Bool - func schedule(_ action: @escaping () -> Void) + func schedule(_ action: @escaping @Sendable () -> Void) } extension ValueObservationScheduler { - func scheduleInitial(_ action: @escaping () -> Void) { + func scheduleInitial(_ action: @escaping @Sendable () -> Void) { if immediateInitialValue() { action() } else { @@ -42,7 +42,7 @@ public struct AsyncValueObservationScheduler: ValueObservationScheduler { public func immediateInitialValue() -> Bool { false } - public func schedule(_ action: @escaping () -> Void) { + public func schedule(_ action: @escaping @Sendable () -> Void) { queue.async(execute: action) } } @@ -90,7 +90,7 @@ public struct ImmediateValueObservationScheduler: ValueObservationScheduler, Sen return true } - public func schedule(_ action: @escaping () -> Void) { + public func schedule(_ action: @escaping @Sendable () -> Void) { DispatchQueue.main.async(execute: action) } } diff --git a/Package.swift b/Package.swift index 22cd2d6c3e..f08e63fe4e 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ import PackageDescription var swiftSettings: [SwiftSetting] = [ .define("SQLITE_ENABLE_FTS5"), + .enableUpcomingFeature("InferSendableFromCaptures") ] var cSettings: [CSetting] = [] var dependencies: [PackageDescription.Package.Dependency] = [] diff --git a/SQLiteCustom/GRDBDeploymentTarget.xcconfig b/SQLiteCustom/GRDBDeploymentTarget.xcconfig index 3006917366..7fed5abdec 100644 --- a/SQLiteCustom/GRDBDeploymentTarget.xcconfig +++ b/SQLiteCustom/GRDBDeploymentTarget.xcconfig @@ -2,3 +2,4 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0 MACOSX_DEPLOYMENT_TARGET = 10.13 TVOS_DEPLOYMENT_TARGET = 12.0 WATCHOS_DEPLOYMENT_TARGET = 7.0 +SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES diff --git a/Support/GRDBDeploymentTarget.xcconfig b/Support/GRDBDeploymentTarget.xcconfig index 50434f84d7..cd39973b9a 100644 --- a/Support/GRDBDeploymentTarget.xcconfig +++ b/Support/GRDBDeploymentTarget.xcconfig @@ -3,6 +3,7 @@ MACOSX_DEPLOYMENT_TARGET = 10.13 TVOS_DEPLOYMENT_TARGET = 12.0 WATCHOS_DEPLOYMENT_TARGET = 7.0 OTHER_SWIFT_FLAGS = $(inherited) -D SQLITE_ENABLE_FTS5 +SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES //// Compile with all opt-in APIs //GCC_PREPROCESSOR_DEFINITIONS = $(inherited) GRDB_SQLITE_ENABLE_PREUPDATE_HOOK=1 diff --git a/Tests/GRDBTests/DatabaseAfterNextTransactionCommitTests.swift b/Tests/GRDBTests/DatabaseAfterNextTransactionCommitTests.swift index 3daa8f55cf..9e01b79a8b 100644 --- a/Tests/GRDBTests/DatabaseAfterNextTransactionCommitTests.swift +++ b/Tests/GRDBTests/DatabaseAfterNextTransactionCommitTests.swift @@ -42,7 +42,7 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() try dbQueue.writeWithoutTransaction { db in - var commitCount = 0 + let commitCountMutex = Mutex(0) weak var deallocationWitness: Witness? = nil do { let witness = Witness() @@ -51,19 +51,19 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { db.afterNextTransaction { _ in // use witness withExtendedLifetime(witness, { }) - commitCount += 1 + commitCountMutex.increment() } } XCTAssertNotNil(deallocationWitness) - XCTAssertEqual(commitCount, 0) + XCTAssertEqual(commitCountMutex.load(), 0) try db.execute(sql: startSQL) try db.execute(sql: endSQL) switch expectedCompletion { case .commit: - XCTAssertEqual(commitCount, 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 1, "\(startSQL); \(endSQL)") case .rollback: - XCTAssertEqual(commitCount, 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 0, "\(startSQL); \(endSQL)") } XCTAssertNil(deallocationWitness) @@ -73,9 +73,9 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { } switch expectedCompletion { case .commit: - XCTAssertEqual(commitCount, 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 1, "\(startSQL); \(endSQL)") case .rollback: - XCTAssertEqual(commitCount, 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 0, "\(startSQL); \(endSQL)") } } } @@ -85,8 +85,8 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() try dbQueue.writeWithoutTransaction { db in - var commitCount = 0 - var rollbackCount = 0 + let commitCountMutex = Mutex(0) + let rollbackCountMutex = Mutex(0) try db.execute(sql: startSQL) weak var deallocationWitness: Witness? = nil @@ -98,25 +98,25 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { onCommit: { _ in // use witness withExtendedLifetime(witness, { }) - commitCount += 1 + commitCountMutex.increment() }, onRollback: { _ in // use witness withExtendedLifetime(witness, { }) - rollbackCount += 1 + rollbackCountMutex.increment() }) } XCTAssertNotNil(deallocationWitness) - XCTAssertEqual(commitCount, 0) + XCTAssertEqual(commitCountMutex.load(), 0) try db.execute(sql: endSQL) switch expectedCompletion { case .commit: - XCTAssertEqual(commitCount, 1, "\(startSQL); \(endSQL)") - XCTAssertEqual(rollbackCount, 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(rollbackCountMutex.load(), 0, "\(startSQL); \(endSQL)") case .rollback: - XCTAssertEqual(commitCount, 0, "\(startSQL); \(endSQL)") - XCTAssertEqual(rollbackCount, 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(rollbackCountMutex.load(), 1, "\(startSQL); \(endSQL)") } XCTAssertNil(deallocationWitness) @@ -126,11 +126,11 @@ class DatabaseAfterNextTransactionCommitTests: GRDBTestCase { } switch expectedCompletion { case .commit: - XCTAssertEqual(commitCount, 1, "\(startSQL); \(endSQL)") - XCTAssertEqual(rollbackCount, 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(rollbackCountMutex.load(), 0, "\(startSQL); \(endSQL)") case .rollback: - XCTAssertEqual(commitCount, 0, "\(startSQL); \(endSQL)") - XCTAssertEqual(rollbackCount, 1, "\(startSQL); \(endSQL)") + XCTAssertEqual(commitCountMutex.load(), 0, "\(startSQL); \(endSQL)") + XCTAssertEqual(rollbackCountMutex.load(), 1, "\(startSQL); \(endSQL)") } } } diff --git a/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift b/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift index 93eaaae3e9..7d2478545b 100644 --- a/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift +++ b/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift @@ -1078,13 +1078,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { func testAsyncConcurrentReadOpensATransaction() throws { let dbPool = try makeDatabasePool() - var isInsideTransaction: Bool? = nil + let isInsideTransactionMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "read") dbPool.writeWithoutTransaction { db in dbPool.asyncConcurrentRead { dbResult in do { let db = try dbResult.get() - isInsideTransaction = db.isInsideTransaction + isInsideTransactionMutex.store(db.isInsideTransaction) do { try db.execute(sql: "BEGIN DEFERRED TRANSACTION") XCTFail("Expected error") @@ -1097,7 +1097,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { } } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(isInsideTransaction, true) + XCTAssertEqual(isInsideTransactionMutex.load(), true) } func testAsyncConcurrentReadOutsideOfTransaction() throws { @@ -1120,14 +1120,14 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // < // } - var count: Int? = nil + let countMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "read") try dbPool.writeWithoutTransaction { db in dbPool.asyncConcurrentRead { dbResult in do { _ = s1.wait(timeout: .distantFuture) let db = try dbResult.get() - count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM persons")! + try countMutex.store(Int.fetchOne(db, sql: "SELECT COUNT(*) FROM persons")!) } catch { XCTFail("Unexpected error: \(error)") } @@ -1137,14 +1137,14 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { s1.signal() } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 0) + XCTAssertEqual(countMutex.load(), 0) } func testAsyncConcurrentReadError() throws { // Necessary for this test to run as quickly as possible dbConfiguration.readonlyBusyMode = .immediateError let dbPool = try makeDatabasePool() - var readError: DatabaseError? = nil + let readErrorMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "read") try dbPool.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA locking_mode=EXCLUSIVE") @@ -1156,12 +1156,12 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { XCTFail("Unexpected result: \(dbResult)") return } - readError = dbError + readErrorMutex.store(dbError) expectation.fulfill() } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(readError!.resultCode, .SQLITE_BUSY) - XCTAssertEqual(readError!.message!, "database is locked") + XCTAssertEqual(readErrorMutex.load()!.resultCode, .SQLITE_BUSY) + XCTAssertEqual(readErrorMutex.load()!.message!, "database is locked") } } diff --git a/Tests/GRDBTests/DatabasePoolTests.swift b/Tests/GRDBTests/DatabasePoolTests.swift index f1be8493b8..e1ab57bbd5 100644 --- a/Tests/GRDBTests/DatabasePoolTests.swift +++ b/Tests/GRDBTests/DatabasePoolTests.swift @@ -237,9 +237,7 @@ class DatabasePoolTests: GRDBTestCase { let group = DispatchGroup() // The maximum number of threads we could witness - var maxThreadCount: CInt = 0 - let lock = NSLock() - + let maxThreadCountMutex: Mutex = Mutex(0) for _ in (0.. = Mutex(0) for _ in (0.. = Mutex(nil) dbReader.asyncRead { dbResult in // Make sure this block executes asynchronously semaphore.wait() do { - count = try Int.fetchOne(dbResult.get(), sql: "SELECT COUNT(*) FROM sqlite_master") + try countMutex.store(Int.fetchOne(dbResult.get(), sql: "SELECT COUNT(*) FROM sqlite_master")) } catch { XCTFail("Unexpected error: \(error)") } @@ -238,7 +238,7 @@ class DatabaseReaderTests : GRDBTestCase { semaphore.signal() waitForExpectations(timeout: 1, handler: nil) - XCTAssertNotNil(count) + XCTAssertNotNil(countMutex.load()) } try test(makeDatabaseQueue()) diff --git a/Tests/GRDBTests/DatabaseRegionObservationTests.swift b/Tests/GRDBTests/DatabaseRegionObservationTests.swift index 9d54aafbef..341616b37f 100644 --- a/Tests/GRDBTests/DatabaseRegionObservationTests.swift +++ b/Tests/GRDBTests/DatabaseRegionObservationTests.swift @@ -27,12 +27,12 @@ class DatabaseRegionObservationTests: GRDBTestCase { let observation = DatabaseRegionObservation(tracking: .fullDatabase) - var count = 0 + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, onChange: { db in - count += 1 + countMutex.increment() notificationExpectation.fulfill() }) @@ -49,7 +49,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 3) + XCTAssertEqual(countMutex.load(), 3) } } @@ -96,12 +96,12 @@ class DatabaseRegionObservationTests: GRDBTestCase { let observation = DatabaseRegionObservation(tracking: request1, request2) - var count = 0 + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, onChange: { db in - count += 1 + countMutex.increment() notificationExpectation.fulfill() }) @@ -118,7 +118,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 3) + XCTAssertEqual(countMutex.load(), 3) } } @@ -138,12 +138,12 @@ class DatabaseRegionObservationTests: GRDBTestCase { let observation = DatabaseRegionObservation(tracking: [request1, request2]) - var count = 0 + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, onChange: { db in - count += 1 + countMutex.increment() notificationExpectation.fulfill() }) @@ -160,7 +160,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 3) + XCTAssertEqual(countMutex.load(), 3) } } @@ -174,13 +174,13 @@ class DatabaseRegionObservationTests: GRDBTestCase { let observation = DatabaseRegionObservation(tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) - var count = 0 + let countMutex = Mutex(0) do { let cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, onChange: { db in - count += 1 + countMutex.increment() notificationExpectation.fulfill() }) @@ -199,7 +199,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 2) + XCTAssertEqual(countMutex.load(), 2) } func testDatabaseRegionExtentNextTransaction() throws { @@ -212,14 +212,14 @@ class DatabaseRegionObservationTests: GRDBTestCase { let observation = DatabaseRegionObservation(tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) - var count = 0 - var cancellable: AnyDatabaseCancellable? + let countMutex = Mutex(0) + nonisolated(unsafe) var cancellable: AnyDatabaseCancellable? cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, onChange: { db in cancellable?.cancel() - count += 1 + countMutex.increment() notificationExpectation.fulfill() }) @@ -233,7 +233,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(count, 1) + XCTAssertEqual(countMutex.load(), 1) } } diff --git a/Tests/GRDBTests/SharedValueObservationTests.swift b/Tests/GRDBTests/SharedValueObservationTests.swift index 572f362ff3..dc9baa1f9c 100644 --- a/Tests/GRDBTests/SharedValueObservationTests.swift +++ b/Tests/GRDBTests/SharedValueObservationTests.swift @@ -39,12 +39,12 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated withExtendedLifetime(sharedObservation) { sharedObservation in do { - var value: Int? + let valueMutex: Mutex = Mutex(nil) let cancellable = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { value = $0 }) + onChange: { value in valueMutex.store(value) }) - XCTAssertEqual(value, 0) + XCTAssertEqual(valueMutex.load(), 0) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"]) cancellable.cancel() @@ -52,12 +52,12 @@ class SharedValueObservationTests: GRDBTestCase { } do { - var value: Int? + let valueMutex: Mutex = Mutex(nil) let cancellable = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { value = $0 }) + onChange: { value in valueMutex.store(value) }) - XCTAssertEqual(value, 0) + XCTAssertEqual(valueMutex.load(), 0) XCTAssertEqual(log.flush(), []) cancellable.cancel() @@ -91,21 +91,21 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated withExtendedLifetime(sharedObservation) { sharedObservation in do { - var value1: Int? - var value2: Int? + let value1Mutex: Mutex = Mutex(nil) + let value2Mutex: Mutex = Mutex(nil) let cancellable1 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, onChange: { value in - value1 = value + value1Mutex.store(value) _ = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, onChange: { value in - value2 = value + value2Mutex.store(value) }) }) - XCTAssertEqual(value1, 0) - XCTAssertEqual(value2, 0) + XCTAssertEqual(value1Mutex.load(), 0) + XCTAssertEqual(value2Mutex.load(), 0) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"]) cancellable1.cancel() @@ -175,12 +175,12 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated withExtendedLifetime(sharedObservation) { sharedObservation in do { - var value: Int? + let valueMutex: Mutex = Mutex(nil) let cancellable = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { value = $0 }) + onChange: { value in valueMutex.store(value) }) - XCTAssertEqual(value, 0) + XCTAssertEqual(valueMutex.load(), 0) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"]) cancellable.cancel() @@ -188,12 +188,12 @@ class SharedValueObservationTests: GRDBTestCase { } do { - var value: Int? + let valueMutex: Mutex = Mutex(nil) let cancellable = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { value = $0 }) + onChange: { value in valueMutex.store(value) }) - XCTAssertEqual(value, 0) + XCTAssertEqual(valueMutex.load(), 0) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"]) cancellable.cancel() @@ -226,21 +226,21 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated withExtendedLifetime(sharedObservation) { sharedObservation in - var value1: Int? - var value2: Int? + let value1Mutex: Mutex = Mutex(nil) + let value2Mutex: Mutex = Mutex(nil) let cancellable1 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, onChange: { value in - value1 = value + value1Mutex.store(value) _ = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, onChange: { value in - value2 = value + value2Mutex.store(value) }) }) - XCTAssertEqual(value1, 0) - XCTAssertEqual(value2, 0) + XCTAssertEqual(value1Mutex.load(), 0) + XCTAssertEqual(value2Mutex.load(), 0) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"]) cancellable1.cancel() @@ -273,40 +273,40 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated try withExtendedLifetime(sharedObservation) { sharedObservation in // --- Start observation 1 - var values1: [Int] = [] + let values1Mutex: Mutex<[Int]> = Mutex([]) let exp1 = expectation(description: "") exp1.expectedFulfillmentCount = 2 exp1.assertForOverFulfill = false let cancellable1 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values1.append($0) + onChange: { value in + values1Mutex.withLock { $0.append(value) } exp1.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp1], timeout: 1) - XCTAssertEqual(values1, [0, 1]) + XCTAssertEqual(values1Mutex.load(), [0, 1]) XCTAssertEqual(log.flush(), [ "start", "fetch", "tracked region: player(*)", "value: 0", "database did change", "fetch", "value: 1"]) // --- Start observation 2 - var values2: [Int] = [] + let values2Mutex: Mutex<[Int]> = Mutex([]) let exp2 = expectation(description: "") exp2.expectedFulfillmentCount = 2 exp2.assertForOverFulfill = false let cancellable2 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values2.append($0) + onChange: { value in + values2Mutex.withLock { $0.append(value) } exp2.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp2], timeout: 1) - XCTAssertEqual(values1, [0, 1, 2]) - XCTAssertEqual(values2, [1, 2]) + XCTAssertEqual(values1Mutex.load(), [0, 1, 2]) + XCTAssertEqual(values2Mutex.load(), [1, 2]) XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 2"]) // --- Stop observation 1 @@ -314,22 +314,22 @@ class SharedValueObservationTests: GRDBTestCase { XCTAssertEqual(log.flush(), []) // --- Start observation 3 - var values3: [Int] = [] + let values3Mutex: Mutex<[Int]> = Mutex([]) let exp3 = expectation(description: "") exp3.expectedFulfillmentCount = 2 exp3.assertForOverFulfill = false let cancellable3 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values3.append($0) + onChange: { value in + values3Mutex.withLock { $0.append(value) } exp3.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp3], timeout: 1) - XCTAssertEqual(values1, [0, 1, 2]) - XCTAssertEqual(values2, [1, 2, 3]) - XCTAssertEqual(values3, [2, 3]) + XCTAssertEqual(values1Mutex.load(), [0, 1, 2]) + XCTAssertEqual(values2Mutex.load(), [1, 2, 3]) + XCTAssertEqual(values3Mutex.load(), [2, 3]) XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 3"]) // --- Stop observation 2 @@ -355,7 +355,7 @@ class SharedValueObservationTests: GRDBTestCase { } let log = Log() - var sharedObservation: SharedValueObservation? = ValueObservation + nonisolated(unsafe) var sharedObservation: SharedValueObservation? = ValueObservation .tracking(Table("player").fetchCount) .print(to: log) .shared( @@ -452,40 +452,40 @@ class SharedValueObservationTests: GRDBTestCase { // We want to control when the shared observation is deallocated try withExtendedLifetime(sharedObservation) { sharedObservation in // --- Start observation 1 - var values1: [Int] = [] + let values1Mutex: Mutex<[Int]> = Mutex([]) let exp1 = expectation(description: "") exp1.expectedFulfillmentCount = 2 exp1.assertForOverFulfill = false let cancellable1 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values1.append($0) + onChange: { value in + values1Mutex.withLock { $0.append(value) } exp1.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp1], timeout: 1) - XCTAssertEqual(values1, [0, 1]) + XCTAssertEqual(values1Mutex.load(), [0, 1]) XCTAssertEqual(log.flush(), [ "start", "fetch", "tracked region: player(*)", "value: 0", "database did change", "fetch", "value: 1"]) // --- Start observation 2 - var values2: [Int] = [] + let values2Mutex: Mutex<[Int]> = Mutex([]) let exp2 = expectation(description: "") exp2.expectedFulfillmentCount = 2 exp2.assertForOverFulfill = false let cancellable2 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values2.append($0) + onChange: { value in + values2Mutex.withLock { $0.append(value) } exp2.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp2], timeout: 1) - XCTAssertEqual(values1, [0, 1, 2]) - XCTAssertEqual(values2, [1, 2]) + XCTAssertEqual(values1Mutex.load(), [0, 1, 2]) + XCTAssertEqual(values2Mutex.load(), [1, 2]) XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 2"]) // --- Stop observation 1 @@ -493,22 +493,22 @@ class SharedValueObservationTests: GRDBTestCase { XCTAssertEqual(log.flush(), []) // --- Start observation 3 - var values3: [Int] = [] + let values3Mutex: Mutex<[Int]> = Mutex([]) let exp3 = expectation(description: "") exp3.expectedFulfillmentCount = 2 exp3.assertForOverFulfill = false let cancellable3 = sharedObservation!.start( onError: { XCTFail("Unexpected error \($0)") }, - onChange: { - values3.append($0) + onChange: { value in + values3Mutex.withLock { $0.append(value) } exp3.fulfill() }) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} wait(for: [exp3], timeout: 1) - XCTAssertEqual(values1, [0, 1, 2]) - XCTAssertEqual(values2, [1, 2, 3]) - XCTAssertEqual(values3, [2, 3]) + XCTAssertEqual(values1Mutex.load(), [0, 1, 2]) + XCTAssertEqual(values2Mutex.load(), [1, 2, 3]) + XCTAssertEqual(values3Mutex.load(), [2, 3]) XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 3"]) // --- Stop observation 2 @@ -539,10 +539,12 @@ class SharedValueObservationTests: GRDBTestCase { } let log = Log() - var fetchError: Error? = nil + let fetchErrorMutex: Mutex = Mutex(nil) let publisher = ValueObservation .tracking { db -> Int in - if let error = fetchError { throw error } + try fetchErrorMutex.withLock { error in + if let error { throw error } + } return try Table("player").fetchCount(db) } .print(to: log) @@ -556,7 +558,7 @@ class SharedValueObservationTests: GRDBTestCase { try XCTAssertEqual(wait(for: recorder1.next(), timeout: 1), 0) try XCTAssertEqual(wait(for: recorder2.next(), timeout: 1), 0) - fetchError = TestError() + fetchErrorMutex.store(TestError()) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} if case .finished = try wait(for: recorder1.completion, timeout: 1) { XCTFail("Expected error") } @@ -573,7 +575,7 @@ class SharedValueObservationTests: GRDBTestCase { } do { - fetchError = nil + fetchErrorMutex.store(nil) let recorder = publisher.record() if case .finished = try wait(for: recorder.completion, timeout: 1) { XCTFail("Expected error") } XCTAssertEqual(log.flush(), []) @@ -595,10 +597,12 @@ class SharedValueObservationTests: GRDBTestCase { } let log = Log() - var fetchError: Error? = nil + let fetchErrorMutex: Mutex = Mutex(nil) let publisher = ValueObservation .tracking { db -> Int in - if let error = fetchError { throw error } + try fetchErrorMutex.withLock { error in + if let error { throw error } + } return try Table("player").fetchCount(db) } .print(to: log) @@ -612,7 +616,7 @@ class SharedValueObservationTests: GRDBTestCase { try XCTAssertEqual(wait(for: recorder1.next(), timeout: 1), 0) try XCTAssertEqual(wait(for: recorder2.next(), timeout: 1), 0) - fetchError = TestError() + fetchErrorMutex.store(TestError()) try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")} if case .finished = try wait(for: recorder1.completion, timeout: 1) { XCTFail("Expected error") } @@ -629,7 +633,7 @@ class SharedValueObservationTests: GRDBTestCase { } do { - fetchError = nil + fetchErrorMutex.store(nil) let recorder = publisher.record() try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 1) XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 1"]) diff --git a/Tests/GRDBTests/ValueObservationPrintTests.swift b/Tests/GRDBTests/ValueObservationPrintTests.swift index c2940c757b..9ed1a44333 100644 --- a/Tests/GRDBTests/ValueObservationPrintTests.swift +++ b/Tests/GRDBTests/ValueObservationPrintTests.swift @@ -411,11 +411,15 @@ class ValueObservationPrintTests: GRDBTestCase { // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation .trackingConstantRegion { db -> Int? in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO player DEFAULT VALUES; @@ -462,11 +466,15 @@ class ValueObservationPrintTests: GRDBTestCase { // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation .trackingConstantRegion { db -> Int? in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO player DEFAULT VALUES; diff --git a/Tests/GRDBTests/ValueObservationRegionRecordingTests.swift b/Tests/GRDBTests/ValueObservationRegionRecordingTests.swift index 90bd8beb8b..390df4af0a 100644 --- a/Tests/GRDBTests/ValueObservationRegionRecordingTests.swift +++ b/Tests/GRDBTests/ValueObservationRegionRecordingTests.swift @@ -130,24 +130,26 @@ class ValueObservationRegionRecordingTests: GRDBTestCase { """) } - var results: [Int] = [] + let resultsMutex: Mutex<[Int]> = Mutex([]) let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 4 - var regions: [DatabaseRegion] = [] + let regionsMutex: Mutex<[DatabaseRegion]> = Mutex([]) let observation = ValueObservation .tracking { db -> Int in let table = try String.fetchOne(db, sql: "SELECT name FROM source")! return try Int.fetchOne(db, sql: "SELECT IFNULL(SUM(value), 0) FROM \(table)")! } - .handleEvents(willTrackRegion: { regions.append($0) }) + .handleEvents(willTrackRegion: { region in + regionsMutex.withLock { $0.append(region) } + }) let observer = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - results.append(count) + resultsMutex.withLock { $0.append(count) } notificationExpectation.fulfill() }) @@ -162,9 +164,9 @@ class ValueObservationRegionRecordingTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(results, [0, 1, 2, 3]) + XCTAssertEqual(resultsMutex.load(), [0, 1, 2, 3]) - XCTAssertEqual(regions.map(\.description), [ + XCTAssertEqual(regionsMutex.load().map(\.description), [ "a(value),source(name)", "b(value),source(name)"]) } @@ -181,25 +183,27 @@ class ValueObservationRegionRecordingTests: GRDBTestCase { """) } - var results: [Int] = [] + let resultsMutex: Mutex<[Int]> = Mutex([]) let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 4 - var regions: [DatabaseRegion] = [] + let regionsMutex: Mutex<[DatabaseRegion]> = Mutex([]) let observation = ValueObservation .tracking { db -> Int in let table = try String.fetchOne(db, sql: "SELECT name FROM source")! return try Int.fetchOne(db, sql: "SELECT IFNULL(SUM(value), 0) FROM \(table)")! } - .handleEvents(willTrackRegion: { regions.append($0) }) + .handleEvents(willTrackRegion: { region in + regionsMutex.withLock { $0.append(region) } + }) let observer = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - results.append(count) + resultsMutex.withLock { $0.append(count) } notificationExpectation.fulfill() }) @@ -214,9 +218,9 @@ class ValueObservationRegionRecordingTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(results, [0, 1, 2, 3]) + XCTAssertEqual(resultsMutex.load(), [0, 1, 2, 3]) - XCTAssertEqual(regions.map(\.description), [ + XCTAssertEqual(regionsMutex.load().map(\.description), [ "a(value),source(name)", "b(value),source(name)"]) } diff --git a/Tests/GRDBTests/ValueObservationTests.swift b/Tests/GRDBTests/ValueObservationTests.swift index 7699a94396..08b59e1d5c 100644 --- a/Tests/GRDBTests/ValueObservationTests.swift +++ b/Tests/GRDBTests/ValueObservationTests.swift @@ -38,13 +38,15 @@ class ValueObservationTests: GRDBTestCase { let observation = ValueObservation.trackingConstantRegion { _ in throw TestError() } // Start observation - var error: TestError? + let errorMutex: Mutex = Mutex(nil) _ = observation.start( in: dbWriter, scheduling: .immediate, - onError: { error = $0 as? TestError }, + onError: { error in + errorMutex.store(error as? TestError) + }, onChange: { _ in }) - XCTAssertNotNil(error) + XCTAssertNotNil(errorMutex.load()) } try test(makeDatabaseQueue()) @@ -64,25 +66,25 @@ class ValueObservationTests: GRDBTestCase { notificationExpectation.expectedFulfillmentCount = 4 notificationExpectation.isInverted = true - var nextError: Error? = nil // If not null, observation throws an error + let nextErrorMutex: Mutex = Mutex(nil) // If not null, observation throws an error let observation = ValueObservation.trackingConstantRegion { _ = try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t") - if let error = nextError { - throw error + try nextErrorMutex.withLock { error in + if let error { throw error } } } // Start observation - var errorCaught = false + let errorCaughtMutex = Mutex(false) let cancellable = observation.start( in: dbWriter, onError: { _ in - errorCaught = true + errorCaughtMutex.store(true) notificationExpectation.fulfill() }, onChange: { - XCTAssertFalse(errorCaught) - nextError = TestError() + XCTAssertFalse(errorCaughtMutex.load()) + nextErrorMutex.store(TestError()) notificationExpectation.fulfill() // Trigger another change try! dbWriter.writeWithoutTransaction { db in @@ -92,7 +94,7 @@ class ValueObservationTests: GRDBTestCase { withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertTrue(errorCaught) + XCTAssertTrue(errorCaughtMutex.load()) } } @@ -119,12 +121,12 @@ class ValueObservationTests: GRDBTestCase { // Test that view v is not included in the observed region. // This optimization helps observation of views that feed from a // single table. - var region: DatabaseRegion? + let regionMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "") let observation = ValueObservation - .trackingConstantRegion(request.fetchAll) - .handleEvents(willTrackRegion: { - region = $0 + .trackingConstantRegion { _ = try request.fetchAll($0) } + .handleEvents(willTrackRegion: { region in + regionMutex.store(region) expectation.fulfill() }) let observer = observation.start( @@ -133,7 +135,7 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(region!.description, "t(id,name)") // view is NOT tracked + XCTAssertEqual(regionMutex.load()!.description, "t(id,name)") // view is NOT tracked } } @@ -152,12 +154,12 @@ class ValueObservationTests: GRDBTestCase { // Test that no pragma table is included in the observed region. // This optimization helps observation that feed from a single table. - var region: DatabaseRegion? + let regionMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "") let observation = ValueObservation - .trackingConstantRegion(request.fetchAll) - .handleEvents(willTrackRegion: { - region = $0 + .trackingConstantRegion{ _ = try request.fetchAll($0) } + .handleEvents(willTrackRegion: { region in + regionMutex.store(region) expectation.fulfill() }) let observer = observation.start( @@ -166,7 +168,7 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(region!.description, "t(id,name)[1]") // pragma_table_xinfo is NOT tracked + XCTAssertEqual(regionMutex.load()?.description, "t(id,name)[1]") // pragma_table_xinfo is NOT tracked } } @@ -353,10 +355,14 @@ class ValueObservationTests: GRDBTestCase { // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation.trackingConstantRegion { db -> Int in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; @@ -369,18 +375,18 @@ class ValueObservationTests: GRDBTestCase { let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 - var observedCounts: [Int] = [] + let observedCountsMutex: Mutex<[Int]> = Mutex([]) let cancellable = observation.start( in: dbPool, scheduling: .async(onQueue: .main), onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - observedCounts.append(count) + observedCountsMutex.withLock { $0.append(count) } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(observedCounts, [0, 0]) + XCTAssertEqual(observedCountsMutex.load(), [0, 0]) } } @@ -393,10 +399,14 @@ class ValueObservationTests: GRDBTestCase { // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation.trackingConstantRegion { db -> Int in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; @@ -409,18 +419,18 @@ class ValueObservationTests: GRDBTestCase { let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 - var observedCounts: [Int] = [] + let observedCountsMutex: Mutex<[Int]> = Mutex([]) let cancellable = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - observedCounts.append(count) + observedCountsMutex.withLock { $0.append(count) } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(observedCounts, [0, 0]) + XCTAssertEqual(observedCountsMutex.load(), [0, 0]) } } @@ -433,10 +443,14 @@ class ValueObservationTests: GRDBTestCase { // Allow pool to perform a single initial fetch, because between // its first read access, and its write access that installs the // transaction observer, no write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation.trackingConstantRegion { db -> Int in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in try db.execute(sql: """ @@ -459,18 +473,18 @@ class ValueObservationTests: GRDBTestCase { let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count - var observedCounts: [Int] = [] + let observedCountsMutex: Mutex<[Int]> = Mutex([]) let cancellable = observation.start( in: dbPool, scheduling: .async(onQueue: .main), onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - observedCounts.append(count) + observedCountsMutex.withLock { $0.append(count) } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(observedCounts, expectedCounts) + XCTAssertEqual(observedCountsMutex.load(), expectedCounts) } } @@ -483,10 +497,14 @@ class ValueObservationTests: GRDBTestCase { // Allow pool to perform a single initial fetch, because between // its first read access, and its write access that installs the // transaction observer, no write did happen. - var needsChange = true + let needsChangeMutex = Mutex(true) let observation = ValueObservation.trackingConstantRegion { db -> Int in + let needsChange = needsChangeMutex.withLock { needed in + let wasNeeded = needed + needed = false + return wasNeeded + } if needsChange { - needsChange = false DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in try db.execute(sql: """ @@ -509,18 +527,18 @@ class ValueObservationTests: GRDBTestCase { let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count - var observedCounts: [Int] = [] + let observedCountsMutex: Mutex<[Int]> = Mutex([]) let cancellable = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in - observedCounts.append(count) + observedCountsMutex.withLock { $0.append(count) } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(observedCounts, expectedCounts) + XCTAssertEqual(observedCountsMutex.load(), expectedCounts) } } @@ -602,7 +620,7 @@ class ValueObservationTests: GRDBTestCase { try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Track reducer process - var changesCount = 0 + let changesCountMutex = Mutex(0) let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 2 @@ -613,13 +631,12 @@ class ValueObservationTests: GRDBTestCase { } // Start observation and deallocate cancellable after second change - var cancellable: (any DatabaseCancellable)? + nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in - changesCount += 1 - if changesCount == 2 { + if changesCountMutex.increment() == 2 { cancellable = nil } notificationExpectation.fulfill() @@ -639,7 +656,7 @@ class ValueObservationTests: GRDBTestCase { _ = cancellable waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(changesCount, 2) + XCTAssertEqual(changesCountMutex.load(), 2) } func testCancellableExplicitCancellation() throws { @@ -648,7 +665,7 @@ class ValueObservationTests: GRDBTestCase { try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Track reducer process - var changesCount = 0 + let changesCountMutex = Mutex(0) let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 2 @@ -659,13 +676,12 @@ class ValueObservationTests: GRDBTestCase { } // Start observation and cancel cancellable after second change - var cancellable: (any DatabaseCancellable)! + nonisolated(unsafe) var cancellable: (any DatabaseCancellable)! cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in - changesCount += 1 - if changesCount == 2 { + if changesCountMutex.increment() == 2 { cancellable.cancel() } notificationExpectation.fulfill() @@ -683,7 +699,7 @@ class ValueObservationTests: GRDBTestCase { } waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(changesCount, 2) + XCTAssertEqual(changesCountMutex.load(), 2) } } @@ -697,18 +713,20 @@ class ValueObservationTests: GRDBTestCase { notificationExpectation.expectedFulfillmentCount = 2 do { - var cancellable: (any DatabaseCancellable)? = nil + nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? = nil _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning - var shouldStopObservation = false + let shouldStopObservationMutex = Mutex(false) let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, makeReducer: { AnyValueReducer( fetch: { _ in - if shouldStopObservation { - cancellable = nil /* deallocation */ + shouldStopObservationMutex.withLock { shouldStopObservation in + if shouldStopObservation { + cancellable = nil /* deallocation */ + } + shouldStopObservation = true } - shouldStopObservation = true }, value: { _ in () }) }) @@ -741,19 +759,21 @@ class ValueObservationTests: GRDBTestCase { notificationExpectation.expectedFulfillmentCount = 2 do { - var cancellable: (any DatabaseCancellable)? = nil + nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? = nil _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning - var shouldStopObservation = false + let shouldStopObservationMutex = Mutex(false) let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, makeReducer: { AnyValueReducer( fetch: { _ in }, value: { _ in - if shouldStopObservation { - cancellable = nil /* deallocation right before notification */ + shouldStopObservationMutex.withLock { shouldStopObservation in + if shouldStopObservation { + cancellable = nil /* deallocation right before notification */ + } + shouldStopObservation = true } - shouldStopObservation = true return () }) }) @@ -781,13 +801,13 @@ class ValueObservationTests: GRDBTestCase { try writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Start observing - var counts: [Int] = [] + let countsMutex: Mutex<[Int]> = Mutex([]) let cancellable = ValueObservation .trackingConstantRegion { try Table("t").fetchCount($0) } .start(in: writer) { error in XCTFail("Unexpected error: \(error)") } onChange: { count in - counts.append(count) + countsMutex.withLock { $0.append(count) } } // Perform a write after cancellation, but before the @@ -816,7 +836,7 @@ class ValueObservationTests: GRDBTestCase { // We should not have been notified of the first write, because // it was performed after cancellation. - XCTAssertFalse(counts.contains(1)) + XCTAssertFalse(countsMutex.load().contains(1)) } try test(makeDatabaseQueue())