diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 0bcfae42..bf1d4aab 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -30,7 +30,7 @@ runs: - name: Lint the podspec shell: bash - run: pod lib lint LaunchDarkly.podspec + run: pod lib lint LaunchDarkly.podspec --allow-warnings - name: Run swiftlint shell: bash diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index ba8ef5a7..71ceaa97 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -260,6 +260,15 @@ A380B09A2B60178D00AB64A6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */; }; A380B09B2B60178D00AB64A6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */; }; A380B09C2B60178D00AB64A6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */; }; + A3A8BCD22B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; + A3A8BCD32B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; + A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; + A3A8BCD52B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; + A3C6F7622B7FA803005B3B61 /* SheddingQueueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */; }; + A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; + A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; + A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; + A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */; }; B40B419C249ADA6B00CD0726 /* DiagnosticCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */; }; B4265EB124E7390C001CFD2C /* TestUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4265EB024E7390C001CFD2C /* TestUtil.swift */; }; @@ -476,6 +485,9 @@ A36EDFCC2853C50B00D91B05 /* ObjcLDContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjcLDContext.swift; sourceTree = ""; }; A3799D4429033665008D4A8E /* ObjcLDApplicationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcLDApplicationInfo.swift; sourceTree = ""; }; A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheddingQueue.swift; sourceTree = ""; }; + A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheddingQueueSpec.swift; sourceTree = ""; }; + A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyResult.swift; sourceTree = ""; }; A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoderSpec.swift; sourceTree = ""; }; B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticCacheSpec.swift; sourceTree = ""; }; B4265EB024E7390C001CFD2C /* TestUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtil.swift; sourceTree = ""; }; @@ -565,6 +577,7 @@ 837406D321F760640087B22B /* LDTimerSpec.swift */, 831AAE2F20A9E75D00B46DBA /* ThrottlerSpec.swift */, 8354AC75224316C700CDE602 /* Cache */, + A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */, ); path = ServiceObjects; sourceTree = ""; @@ -681,6 +694,7 @@ 8354EFDE1F26380700C05156 /* Event.swift */, 83EBCB9D20D9A0A1003A7142 /* FeatureFlag */, 8354EFDD1F26380700C05156 /* LDConfig.swift */, + A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */, ); path = Models; sourceTree = ""; @@ -806,6 +820,7 @@ 83FEF8D91F2666BF001CF12C /* ServiceObjects */ = { isa = PBXGroup; children = ( + A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */, A358D6CF2A4DD45000270C60 /* EnvironmentReporting */, 8354AC742243168800CDE602 /* Cache */, 838F96771FBA504A009CFC45 /* ClientServiceFactory.swift */, @@ -1256,6 +1271,7 @@ A3470C3A2B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, 831188592113AE1200D77CB5 /* FlagStore.swift in Sources */, + A3A8BCD52B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, C443A40D2315AA4D00145710 /* NetworkReporter.swift in Sources */, A358D6EF2A4DE9A600270C60 /* TVOSEnvironmentReporter.swift in Sources */, 29FE129B280413D4008CC918 /* Util.swift in Sources */, @@ -1274,6 +1290,7 @@ B4C9D43B2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, C443A40523145FBF00145710 /* ConnectionInformation.swift in Sources */, B468E71324B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, + A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, 8354AC732243166900CDE602 /* FeatureFlagCache.swift in Sources */, 8311885B2113AE1D00D77CB5 /* Throttler.swift in Sources */, 8311884E2113ADE500D77CB5 /* Event.swift in Sources */, @@ -1301,6 +1318,7 @@ B468E71224B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, A36EDFCF2853C50B00D91B05 /* ObjcLDContext.swift in Sources */, 831EF34320655E730001C643 /* LDCommon.swift in Sources */, + A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, 831EF34420655E730001C643 /* LDConfig.swift in Sources */, A31088212837DC0400184942 /* LDContext.swift in Sources */, 831EF34520655E730001C643 /* LDClient.swift in Sources */, @@ -1357,6 +1375,7 @@ 83B1D7C92073F354006D1B1C /* CwlSysctl.swift in Sources */, 831EF36620655E730001C643 /* ObjcLDClient.swift in Sources */, 831EF36720655E730001C643 /* ObjcLDConfig.swift in Sources */, + A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, B4C9D43A2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, 831EF36A20655E730001C643 /* ObjcLDChangedFlag.swift in Sources */, ); @@ -1391,6 +1410,7 @@ A3470C372B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C43C37E1236BA050003C1624 /* LDEvaluationDetail.swift in Sources */, 831AAE2C20A9E4F600B46DBA /* Throttler.swift in Sources */, + A3A8BCD22B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, 8354EFE11F26380700C05156 /* LDConfig.swift in Sources */, 29FE1298280413D4008CC918 /* Util.swift in Sources */, C443A40F23186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, @@ -1409,6 +1429,7 @@ 83B6C4B61F4DE7630055351C /* LDCommon.swift in Sources */, B4C9D4382489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, 8347BB0C21F147E100E56BCD /* LDTimer.swift in Sources */, + A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, B468E71024B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, 8354AC702243166900CDE602 /* FeatureFlagCache.swift in Sources */, A36EDFC82853883400D91B05 /* ObjcLDReference.swift in Sources */, @@ -1456,6 +1477,7 @@ 83F0A5641FB5F33800550A95 /* LDConfigSpec.swift in Sources */, 83CFE7D11F7AD8DC0010544E /* DarklyServiceMock.swift in Sources */, 832307AA1F7ECA630029815A /* LDConfigStub.swift in Sources */, + A3C6F7622B7FA803005B3B61 /* SheddingQueueSpec.swift in Sources */, A33A5F7A28466D04000C29C7 /* LDContextStub.swift in Sources */, 8354AC77224316F800CDE602 /* FeatureFlagCacheSpec.swift in Sources */, A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */, @@ -1509,6 +1531,7 @@ A3470C382B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C43C37E6238DF22B003C1624 /* LDEvaluationDetail.swift in Sources */, 83D9EC872062DEAB004D7FA6 /* FlagSynchronizer.swift in Sources */, + A3A8BCD32B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, 29FE1299280413D4008CC918 /* Util.swift in Sources */, 83D9EC882062DEAB004D7FA6 /* FlagChangeNotifier.swift in Sources */, @@ -1527,6 +1550,7 @@ 83D9EC922062DEAB004D7FA6 /* Data.swift in Sources */, 8347BB0D21F147E100E56BCD /* LDTimer.swift in Sources */, 8354AC712243166900CDE602 /* FeatureFlagCache.swift in Sources */, + A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, C443A40323145FB700145710 /* ConnectionInformation.swift in Sources */, B4C9D4392489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, B468E71124B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index acfff012..677a47a7 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -278,15 +278,53 @@ public class LDClient { - parameter context: The LDContext set with the desired context. - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. (Optional) */ + @available(*, deprecated, message: "Use LDClient.identify(context: completion:) with non-optional completion parameter") public func identify(context: LDContext, completion: (() -> Void)? = nil) { - let dispatch = DispatchGroup() - LDClient.instances?.forEach { _, instance in - dispatch.enter() - instance.internalIdentify(newContext: context, completion: dispatch.leave) + _identify(context: context, sheddable: false) { _ in + if let completion = completion { + completion() + } } - if let completion = completion { - dispatch.notify(queue: DispatchQueue.global(), execute: completion) + } + + /** + The LDContext set into the LDClient may affect the set of feature flags returned by the LaunchDarkly server, and ties event tracking to the context. See `LDContext` for details about what information can be retained. + + Normally, the client app should create and set the LDContext and pass that into `start(config: context: completion:)`. + + The client app can change the active `context` by calling identify with a new or updated LDContext. Client apps should follow [Apple's Privacy Policy](apple.com/legal/privacy) when collecting user information. + + When a new context is set, the LDClient goes offline and sets the new context. If the client was online when the new context was set, it goes online again, subject to a throttling delay if in force (see `setOnline(_: completion:)` for details). A completion may be passed to the identify method to allow a client app to know when fresh flag values for the new context are ready. + + While only a single identify request can be active at a time, consumers of this SDK can call this method multiple times. To prevent unnecessary network traffic, these requests are placed + into a sheddable queue. Identify requests will be shed if 1) an existing identify request is in flight, and 2) a third identify has been requested which can be replace the one being shed. + + - parameter context: The LDContext set with the desired context. + - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. + */ + public func identify(context: LDContext, completion: @escaping (_ result: IdentifyResult) -> Void) { + _identify(context: context, sheddable: true, completion: completion) + } + + // Temporary helper method to allow code sharing between the sheddable and unsheddable identify methods. In the next major release, we will remove the deprecated identify method and inline + // this implementation in the other one. + private func _identify(context: LDContext, sheddable: Bool, completion: @escaping (_ result: IdentifyResult) -> Void) { + let work: TaskHandler = { taskCompletion in + let dispatch = DispatchGroup() + + LDClient.instances?.forEach { _, instance in + dispatch.enter() + instance.internalIdentify(newContext: context, completion: dispatch.leave) + } + + dispatch.notify(queue: DispatchQueue.global(), execute: taskCompletion) + } + + let identifyTask = Task(work: work, sheddable: sheddable) { [self] result in + os_log("%s identity completion with result %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: result)) + completion(IdentifyResult(from: result)) } + identifyQueue.enqueue(request: identifyTask) } func internalIdentify(newContext: LDContext, completion: (() -> Void)? = nil) { @@ -711,6 +749,7 @@ public class LDClient { } private var _initialized = false private var initializedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.initializedQueue") + private var identifyQueue = SheddingQueue() private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { self.serviceFactory = serviceFactory diff --git a/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift b/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift new file mode 100644 index 00000000..4cf2d2ee --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift @@ -0,0 +1,30 @@ +import Foundation + +/** + Denotes the result of an identify request made through the `LDClient.identify(context: completion:)` method. + */ +public enum IdentifyResult { + /** + The identify request has completed successfully. + */ + case complete + /** + The identify request has received an unrecoverable failure. + */ + case error + /** + The identify request has been replaced with a subsequent request. See `LDClient.identify(context: completion:)` for more details. + */ + case shed + + init(from: TaskResult) { + switch from { + case .complete: + self = .complete + case .error: + self = .error + case .shed: + self = .shed + } + } +} diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/SheddingQueue.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/SheddingQueue.swift new file mode 100644 index 00000000..5442a5c3 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/SheddingQueue.swift @@ -0,0 +1,68 @@ +import Foundation + +enum TaskResult { + case complete + case error + case shed +} + +typealias TaskHandlerCompletion = () -> Void +typealias TaskHandler = (_ completion: @escaping TaskHandlerCompletion) -> Void +typealias TaskCompletion = (_ result: TaskResult) -> Void + +struct Task { + let work: TaskHandler + let sheddable: Bool + let completion: TaskCompletion +} + +class SheddingQueue { + private let stateQueue: DispatchQueue = DispatchQueue(label: "StateQueue") + private let identifyQueue: DispatchQueue = DispatchQueue(label: "IdentifyQueue") + + private var inFlight: Task? + private var queue: [Task] = [] + + func enqueue(request: Task) { + stateQueue.async { [self] in + guard inFlight != nil else { + inFlight = request + identifyQueue.async { self.execute() } + return + } + + if let lastTask = queue.last, lastTask.sheddable { + queue.removeLast() + lastTask.completion(.shed) + } + + queue.append(request) + } + } + + private func execute() { + var nextTask: Task? + + stateQueue.sync { + nextTask = inFlight + } + + if nextTask == nil { + return + } + + guard let request = nextTask else { return } + + request.work() { [self] in + request.completion(.complete) + + stateQueue.sync { + inFlight = queue.first + if inFlight != nil { + queue.remove(at: 0) + identifyQueue.async { self.execute() } + } + } + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SheddingQueueSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SheddingQueueSpec.swift new file mode 100644 index 00000000..86b82d91 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SheddingQueueSpec.swift @@ -0,0 +1,191 @@ +import Foundation +import XCTest +@testable import LaunchDarkly + +final class SheddingQueueSpec: XCTestCase { + let noop = { (completion: TaskHandlerCompletion) in completion() } + + func testQueueCanCompleteASingleTask() { + let semaphore = DispatchSemaphore(value: 0) + + let task = Task(work: noop, sheddable: true) { result in + XCTAssertEqual(result, .complete) + semaphore.signal() + } + + let queue = SheddingQueue() + queue.enqueue(request: task) + semaphore.wait() + } + + func testQueueCanCompleteTwoTasks() { + let blockFirstWork = DispatchSemaphore(value: 0) + let blockUntilFullyComplete = DispatchSemaphore(value: 0) + + let delayedWork = { (completion: TaskHandlerCompletion) in + blockFirstWork.wait() + completion() + } + + var executionCount = 0 + let firstTask = Task(work: delayedWork, sheddable: true) { result in + XCTAssertEqual(result, .complete) + executionCount += 1 + } + let finalTask = Task(work: noop, sheddable: true) { result in + XCTAssertEqual(result, .complete) + executionCount += 1 + blockUntilFullyComplete.signal() + } + + let queue = SheddingQueue() + queue.enqueue(request: firstTask) + queue.enqueue(request: finalTask) + blockFirstWork.signal() + blockUntilFullyComplete.wait() + + XCTAssertEqual(executionCount, 2) + } + + func testQueueCanShedSubsequentRequests() { + let blockFirstWork = DispatchSemaphore(value: 0) + let blockUntilFullyComplete = DispatchSemaphore(value: 0) + + let delayedWork = { (completion: TaskHandlerCompletion) in + blockFirstWork.wait() + completion() + } + + var sheddedExecutionCount = 0 + let sheddedWork = { (completion: TaskHandlerCompletion) in + sheddedExecutionCount += 1 + completion() + } + + let firstTask = Task(work: delayedWork, sheddable: true) { result in + XCTAssertEqual(result, .complete) + } + var sheddedCount = 0 + let sheddingTask = Task(work: sheddedWork, sheddable: true) { result in + sheddedCount += 1 + XCTAssertEqual(result, .shed) + } + let finalTask = Task(work: noop, sheddable: true) { result in + XCTAssertEqual(result, .complete) + blockUntilFullyComplete.signal() + } + + let queue = SheddingQueue() + queue.enqueue(request: firstTask) + + queue.enqueue(request: sheddingTask) + queue.enqueue(request: sheddingTask) + queue.enqueue(request: sheddingTask) + queue.enqueue(request: sheddingTask) + + queue.enqueue(request: finalTask) + blockFirstWork.signal() + + blockUntilFullyComplete.wait() + + XCTAssertEqual(sheddedExecutionCount, 0) + XCTAssertEqual(sheddedCount, 4) + } + + func testUnsheddableTasksDoNotShed() { + let blockUntilQueued = DispatchSemaphore(value: 0) + let blockUntilComplete = DispatchSemaphore(value: 0) + + let work = { (completion: TaskHandlerCompletion) in + blockUntilQueued.wait() + completion() + blockUntilQueued.signal() + } + + let group = DispatchGroup() + var shedCount = 0 + var completeCount = 0 + let task = Task(work: work, sheddable: false) { result in + switch result { + case .shed: + shedCount += 1 + case .complete: + completeCount += 1 + default: + XCTFail("Task should either shed or complete.") + } + group.leave() + } + + let queue = SheddingQueue() + + for _ in 0...4 { + group.enter() + queue.enqueue(request: task) + } + blockUntilQueued.signal() + + group.notify(queue: .global()) { + blockUntilComplete.signal() + } + + blockUntilComplete.wait() + + XCTAssertEqual(shedCount, 0) + XCTAssertEqual(completeCount, 5) + } + + func testCanMixShedAndUnsheddable() { + let blockUntilQueued = DispatchSemaphore(value: 0) + let blockUntilComplete = DispatchSemaphore(value: 0) + + let work = { (completion: TaskHandlerCompletion) in + blockUntilQueued.wait() + completion() + blockUntilQueued.signal() + } + + let group = DispatchGroup() + var shedCount = 0 + var completeCount = 0 + let completion: TaskCompletion = { result in + switch result { + case .shed: + shedCount += 1 + case .complete: + completeCount += 1 + default: + XCTFail("Task should either shed or complete.") + } + group.leave() + } + let sheddableTask = Task(work: work, sheddable: true, completion: completion) + let unsheddableTask = Task(work: work, sheddable: false, completion: completion) + + let queue = SheddingQueue() + + group.enter() + queue.enqueue(request: sheddableTask) // Will complete, first job + group.enter() + queue.enqueue(request: unsheddableTask) // Will complete, unsheddable + group.enter() + queue.enqueue(request: sheddableTask) // Will be shed + group.enter() + queue.enqueue(request: sheddableTask) // Will be shed + group.enter() + queue.enqueue(request: unsheddableTask) // Will complete, unsheddable + group.enter() + queue.enqueue(request: unsheddableTask) // Will complete, unsheddable + + blockUntilQueued.signal() + + group.notify(queue: .global()) { + blockUntilComplete.signal() + } + + blockUntilComplete.wait() + + XCTAssertEqual(shedCount, 2) + XCTAssertEqual(completeCount, 4) + } +}