Skip to content

Commit f955e5f

Browse files
authored
Merge pull request #1364 from groue/issue1362-test
Fix ValueObservation deadlock
2 parents 5aa3850 + 65c803a commit f955e5f

File tree

5 files changed

+270
-158
lines changed

5 files changed

+270
-158
lines changed

GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,19 @@ final class ValueConcurrentObserver<Reducer: ValueReducer, Scheduler: ValueObser
101101
}
102102
}
103103

104+
/// The fetching state for observation of constant regions.
105+
enum FetchingState {
106+
/// No need to fetch.
107+
case idle
108+
109+
/// Waiting for a fetched value.
110+
case fetching
111+
112+
/// Waiting for a fetched value, and for a subsequent fetch after
113+
/// that, because a change has been detected as we were fetching.
114+
case fetchingAndNeedsFetch
115+
}
116+
104117
/// Ability to notify observation events
105118
private struct NotificationCallbacks {
106119
let events: ValueObservationEvents
@@ -132,6 +145,9 @@ final class ValueConcurrentObserver<Reducer: ValueReducer, Scheduler: ValueObser
132145
/// Ability to notify observation events, protected by `lock`.
133146
private var notificationCallbacks: NotificationCallbacks?
134147

148+
/// The fetching state for observation of constant regions.
149+
@LockedBox private var fetchingState = FetchingState.idle
150+
135151
/// Support for `TransactionObserver`, protected by the serialized writer
136152
/// dispatch queue.
137153
private var observationState = ObservationState.notObserving
@@ -692,13 +708,10 @@ extension ValueConcurrentObserver: TransactionObserver {
692708
events.databaseDidChange?()
693709

694710
// Fetch
695-
let future: DatabaseFuture<Reducer.Fetched>
696-
697711
switch trackingMode {
698712
case .constantRegion, .constantRegionRecordedFromSelection:
699-
future = databaseAccess.dbPool.concurrentRead { db in
700-
try databaseAccess.fetch(db)
701-
}
713+
setNeedsFetching(databaseAccess: databaseAccess)
714+
702715
case .nonConstantRegionRecordedFromSelection:
703716
// When the tracked region is not constant, we can't perform
704717
// concurrent fetches of observed values.
@@ -723,26 +736,63 @@ extension ValueConcurrentObserver: TransactionObserver {
723736
}
724737

725738
observationState.region = observedRegion
726-
future = DatabaseFuture(.success(fetchedValue))
739+
reduce(.success(fetchedValue))
727740
} catch {
728741
stopDatabaseObservation(writerDB)
729742
notifyError(error)
730743
return
731744
}
732745
}
733-
734-
// Reduce
735-
//
736-
// Reducing is performed asynchronously, so that we do not lock
737-
// the writer dispatch queue longer than necessary.
738-
//
739-
// Important: reduceQueue.async guarantees the same ordering between
740-
// transactions and notifications!
746+
}
747+
748+
private func setNeedsFetching(databaseAccess: DatabaseAccess) {
749+
$fetchingState.update { state in
750+
switch state {
751+
case .idle:
752+
state = .fetching
753+
asyncFetch(databaseAccess: databaseAccess)
754+
755+
case .fetching:
756+
state = .fetchingAndNeedsFetch
757+
758+
case .fetchingAndNeedsFetch:
759+
break
760+
}
761+
}
762+
}
763+
764+
private func asyncFetch(databaseAccess: DatabaseAccess) {
765+
databaseAccess.dbPool.asyncRead { [self] dbResult in
766+
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
767+
guard isNotifying else { return /* Cancelled */ }
768+
769+
let fetchResult = dbResult.flatMap { db in
770+
Result { try databaseAccess.fetch(db) }
771+
}
772+
773+
self.reduce(fetchResult)
774+
775+
$fetchingState.update { state in
776+
switch state {
777+
case .idle:
778+
// GRDB bug
779+
preconditionFailure()
780+
781+
case .fetching:
782+
state = .idle
783+
784+
case .fetchingAndNeedsFetch:
785+
state = .fetching
786+
asyncFetch(databaseAccess: databaseAccess)
787+
}
788+
}
789+
}
790+
}
791+
792+
private func reduce(_ fetchResult: Result<Reducer.Fetched, Error>) {
741793
reduceQueue.async {
742794
do {
743-
// Wait until fetch has completed
744-
// TODO: find a way to guarantee correct ordering without waiting for a semaphore and blocking a thread.
745-
let fetchedValue = try future.wait()
795+
let fetchedValue = try fetchResult.get()
746796

747797
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
748798
guard isNotifying else { return /* Cancelled */ }

Makefile

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,17 @@ MAX_IOS_DESTINATION := $(shell xcrun simctl list -j devices available | Scripts/
5252
MIN_TVOS_DESTINATION := $(shell xcrun simctl list -j devices available | Scripts/destination.rb | grep tvOS | sort -n | head -1 | cut -wf 3 | sed 's/\(.*\)/"platform=tvOS Simulator,id=\1"/')
5353
MAX_TVOS_DESTINATION := $(shell xcrun simctl list -j devices available | Scripts/destination.rb | grep tvOS | sort -rn | head -1 | cut -wf 3 | sed 's/\(.*\)/"platform=tvOS Simulator,id=\1"/')
5454

55-
# If xcbeautify or xcpretty is available, use it for xcodebuild output
56-
XCPRETTY =
57-
XCBEAUTIFY_PATH := $(shell command -v xcbeautify 2> /dev/null)
58-
XCPRETTY_PATH := $(shell command -v xcpretty 2> /dev/null)
59-
ifdef XCBEAUTIFY_PATH
60-
XCPRETTY = | xcbeautify
61-
else ifdef XCPRETTY_PATH
62-
XCPRETTY = | xcpretty -c
55+
# If xcbeautify or xcpretty is available, use it for xcodebuild output, except in CI.
56+
XCPRETTY =
57+
ifeq ($(CI),true)
58+
else
59+
XCBEAUTIFY_PATH := $(shell command -v xcbeautify 2> /dev/null)
60+
XCPRETTY_PATH := $(shell command -v xcpretty 2> /dev/null)
61+
ifdef XCBEAUTIFY_PATH
62+
XCPRETTY = | xcbeautify
63+
else ifdef XCPRETTY_PATH
64+
XCPRETTY = | xcpretty -c
65+
endif
6366
endif
6467

6568
# =====

Tests/GRDBTests/ValueObservationRecorder.swift

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -310,43 +310,43 @@ extension XCTestCase {
310310
/// - `[0, 1, 2]` (unexpected value)
311311
/// - `[1, 0, 1]` (unexpected value)
312312
func assertValueObservationRecordingMatch<R, E>(
313-
recorded recordedValues: R,
314-
expected expectedValues: E,
315-
allowMissingLastValue: Bool = false,
313+
recorded: R,
314+
expected: E,
316315
_ message: @autoclosure () -> String = "",
317316
file: StaticString = #file,
318317
line: UInt = #line)
319318
where
320-
R: BidirectionalCollection,
321-
E: BidirectionalCollection,
319+
R: Collection,
320+
E: Collection,
322321
R.Element == E.Element,
323322
R.Element: Equatable
324323
{
325-
guard let value = expectedValues.last else {
326-
if !recordedValues.isEmpty {
327-
XCTFail("unexpected recorded prefix \(Array(recordedValues)) - \(message())", file: file, line: line)
328-
}
329-
return
324+
XCTAssertTrue(
325+
valueObservationRecordingMatch(recorded: recorded, expected: expected),
326+
"Unexpected recording \(Array(recorded)) - \(message())",
327+
file: file, line: line)
328+
}
329+
330+
func valueObservationRecordingMatch<R, E>(
331+
recorded: R,
332+
expected: E)
333+
-> Bool
334+
where R: Collection,
335+
E: Collection,
336+
R.Element == E.Element,
337+
R.Element: Equatable
338+
{
339+
guard let first = recorded.first else {
340+
return expected.isEmpty
330341
}
331342

332-
let recordedSuffix = recordedValues.reversed().prefix(while: { $0 == value })
333-
let expectedSuffix = expectedValues.reversed().prefix(while: { $0 == value })
334-
if !allowMissingLastValue {
335-
// Both missing and repeated values are allowed in the recorded values.
336-
// This is because of asynchronous DatabasePool observations.
337-
if recordedSuffix.isEmpty {
338-
XCTFail("missing expected value \(value) - \(message()) in \(recordedValues)", file: file, line: line)
343+
return expected.indices.lazy
344+
.filter { expected[$0] == first }
345+
.contains {
346+
valueObservationRecordingMatch(
347+
recorded: recorded.drop(while: { $0 == first }),
348+
expected: expected[$0...].dropFirst())
339349
}
340-
}
341-
342-
let remainingRecordedValues = recordedValues.prefix(recordedValues.count - recordedSuffix.count)
343-
let remainingExpectedValues = expectedValues.prefix(expectedValues.count - expectedSuffix.count)
344-
assertValueObservationRecordingMatch(
345-
recorded: remainingRecordedValues,
346-
expected: remainingExpectedValues,
347-
// Other values can be missed
348-
allowMissingLastValue: true,
349-
message(), file: file, line: line)
350350
}
351351
}
352352

0 commit comments

Comments
 (0)