diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 62718801..aa0cc617 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -38,6 +38,11 @@ 3D3AB9462A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; }; 3D3AB9482A570F3A003AECF1 /* ModifierSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */; }; 3D9A12582A73236800698B8D /* UtilSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9A12572A73236800698B8D /* UtilSpec.swift */; }; + 50EE85C22EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; }; + 50EE85C32EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; }; + 50EE85C42EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; }; + 50EE85C52EA0487F007CC662 /* TimeoutExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */; }; + 50EE85C72EA0749C007CC662 /* TimeoutExecutorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */; }; 830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */; }; 830DB3AC22380A3E00D65D25 /* HTTPHeadersSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */; }; 830DB3AE2239B54900D65D25 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AD2239B54900D65D25 /* URLResponse.swift */; }; @@ -429,6 +434,8 @@ 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportingConsts.swift; sourceTree = ""; }; 3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierSpec.swift; sourceTree = ""; }; 3D9A12572A73236800698B8D /* UtilSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilSpec.swift; sourceTree = ""; }; + 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeoutExecutor.swift; sourceTree = ""; }; + 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeoutExecutorSpec.swift; sourceTree = ""; }; 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPURLRequest.swift; sourceTree = ""; }; 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersSpec.swift; sourceTree = ""; }; 830DB3AD2239B54900D65D25 /* URLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLResponse.swift; sourceTree = ""; }; @@ -629,6 +636,7 @@ 831D8B751F72A48900ED65E8 /* ServiceObjects */ = { isa = PBXGroup; children = ( + 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */, A3047D5B2A606A0000F568E0 /* EnvironmentReporting */, B46F344025E6DB7D0078D45F /* DiagnosticReporterSpec.swift */, 83CFE7CD1F7AD81D0010544E /* EventReporterSpec.swift */, @@ -887,6 +895,7 @@ 83FEF8D91F2666BF001CF12C /* ServiceObjects */ = { isa = PBXGroup; children = ( + 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */, A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */, A358D6CF2A4DD45000270C60 /* EnvironmentReporting */, 8354AC742243168800CDE602 /* Cache */, @@ -1355,6 +1364,7 @@ 3D3AB9462A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, 831188522113ADF700D77CB5 /* KeyedValueCache.swift in Sources */, 831188582113AE0F00D77CB5 /* EventReporter.swift in Sources */, + 50EE85C42EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, A358D6F52A4DEB4C00270C60 /* EnvironmentReporterBuilder.swift in Sources */, 8311885D2113AE2500D77CB5 /* DarklyService.swift in Sources */, 831188692113AE5900D77CB5 /* ObjcLDConfig.swift in Sources */, @@ -1481,6 +1491,7 @@ A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, B4C9D43A2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, 831EF36A20655E730001C643 /* ObjcLDChangedFlag.swift in Sources */, + 50EE85C22EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1508,6 +1519,7 @@ 3D3AB9432A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, A31088172837DC0400184942 /* Reference.swift in Sources */, 8354EFE21F26380700C05156 /* Event.swift in Sources */, + 50EE85C52EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, A358D6F22A4DEB4C00270C60 /* EnvironmentReporterBuilder.swift in Sources */, C408884923033B7500420721 /* ConnectionInformation.swift in Sources */, 831D8B721F71D3E700ED65E8 /* DarklyService.swift in Sources */, @@ -1606,6 +1618,7 @@ 838AB53F1F72A7D5006F03F5 /* FlagSynchronizerSpec.swift in Sources */, A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */, A3570F5A28527B8200CF241A /* LDContextCodableSpec.swift in Sources */, + 50EE85C72EA0749C007CC662 /* TimeoutExecutorSpec.swift in Sources */, 837406D421F760640087B22B /* LDTimerSpec.swift in Sources */, 832307A61F7D8D720029815A /* URLRequestSpec.swift in Sources */, A3BA7D022BD192240000DB28 /* LDClientHookSpec.swift in Sources */, @@ -1639,6 +1652,7 @@ 3D3AB9442A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, 83D9EC7E2062DEAB004D7FA6 /* FlagChangeObserver.swift in Sources */, 83D9EC7F2062DEAB004D7FA6 /* FlagsUnchangedObserver.swift in Sources */, + 50EE85C32EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, A358D6F32A4DEB4C00270C60 /* EnvironmentReporterBuilder.swift in Sources */, 83D9EC802062DEAB004D7FA6 /* Event.swift in Sources */, 83D9EC822062DEAB004D7FA6 /* ClientServiceFactory.swift in Sources */, @@ -2017,6 +2031,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + DEVELOPMENT_TEAM = 53D32B66PT; INFOPLIST_FILE = "$(PROJECT_DIR)/LaunchDarkly/LaunchDarklyTests/Info.plist"; PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.DarklyTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2027,6 +2042,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + DEVELOPMENT_TEAM = 53D32B66PT; INFOPLIST_FILE = "$(PROJECT_DIR)/LaunchDarkly/LaunchDarklyTests/Info.plist"; PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.DarklyTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 6b61a2f1..1334b8da 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -384,22 +384,18 @@ public class LDClient { if timeout > LDClient.longTimeoutInterval { os_log("%s LDClient.identify was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval) } - - var cancel = false - - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - guard !cancel else { return } - - cancel = true - completion(.timeout) - } - - identify(context: context, useCache: useCache) { result in - guard !cancel else { return } - - cancel = true - completion(result) - } + + TimeoutExecutor.run( + timeout: timeout, + queue: .global(), + operation: { done in + self.identify(context: context, useCache: useCache) { result in + done(result) + } + }, + timeoutValue: .timeout, + completion: completion + ) } func internalIdentify(newContext: LDContext, useCache: IdentifyCacheUsage, completion: (() -> Void)? = nil) { @@ -854,27 +850,23 @@ public class LDClient { } static func start(serviceFactory: ClientServiceCreating?, config: LDConfig, context: LDContext? = nil, startWaitSeconds: TimeInterval, completion: ((_ timedOut: Bool) -> Void)? = nil) { - var completed = false let internalCompletedQueue: DispatchQueue = DispatchQueue(label: "TimeOutQueue") if !config.startOnline { start(serviceFactory: serviceFactory, config: config, context: context) + // Consider to wrap this into internalCompletedQueue to make completion return always consistent completion?(true) // offline is considered a short circuited timed out case } else { - let startTime = Date().timeIntervalSince1970 - start(serviceFactory: serviceFactory, config: config, context: context) { - internalCompletedQueue.async { - if startTime + startWaitSeconds > Date().timeIntervalSince1970 && !completed { - completed = true - completion?(false) // false for not timedOut + TimeoutExecutor.run( + timeout: startWaitSeconds, + queue: internalCompletedQueue, + operation: { done in + start(serviceFactory: serviceFactory, config: config, context: context) { + done(false) } - } - } - internalCompletedQueue.asyncAfter(deadline: .now() + startWaitSeconds) { - if !completed { - completed = true - completion?(true) // true for timedOut - } - } + }, + timeoutValue: true, + completion: completion + ) } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift new file mode 100644 index 00000000..8b81f908 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A lightweight utility for executing asynchronous operations with a timeout fallback. +/// +/// `TimeoutExecutor` guarantees that the provided `completion` closure is called **exactly once**, +/// either with the result of the asynchronous operation or with a timeout value if the operation +/// does not complete in time. +/// +/// ### Typical Usage +/// ```swift +/// TimeoutExecutor.run( +/// timeout: 2.0, +/// queue: .main, +/// operation: { done in +/// service.action() { +/// done("Success") +/// } +/// }, +/// timeoutValue: "Timeout", +/// completion: { result in +/// print("Result:", result) +/// } +/// ) +/// ``` +/// +final class TimeoutExecutor { + private init() {} + + static func run( + timeout: TimeInterval, + queue: DispatchQueue = .global(), + operation: (@escaping (T) -> Void) -> Void, + timeoutValue: @autoclosure @escaping () -> T, + completion: ((T) -> Void)? + ) { + guard let completion = completion else { + operation { _ in + /* ignore result */ + } + return + } + + let lockQueue = DispatchQueue(label: "launchdarkly.timeout.executor.lock") + var finished = false + + // Start the user operation + operation { value in + var shouldCall = false + lockQueue.sync { + if !finished { + finished = true + shouldCall = true + } + } + + if shouldCall { + queue.async { completion(value) } + } + } + + // Timeout fallback + queue.asyncAfter(deadline: .now() + timeout) { + var shouldCall = false + lockQueue.sync { + if !finished { + finished = true + shouldCall = true + } + } + + if shouldCall { + completion(timeoutValue()) + } + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift new file mode 100644 index 00000000..355d4d2b --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift @@ -0,0 +1,167 @@ +import XCTest + +@testable import LaunchDarkly + +final class TimeoutExecutorSpec: XCTestCase { + + // Create a specific queue and tag it so we can assert where completion ran. + private func makeTaggedQueue(label: String = "com.test.timeout.queue") -> DispatchQueue { + let q = DispatchQueue(label: label) + let key = DispatchSpecificKey() + q.setSpecific(key: key, value: label) + // stash both so tests can read them + queueKey = key + queueLabel = label + return q + } + + private var queueKey = DispatchSpecificKey() + private var queueLabel = "com.test.timeout.queue" + + // MARK: - Tests + + func test_ResultBeforeTimeout_CallsCompletionWithResult() { + let exp = expectation(description: "completion called with result") + let callbackQueue = makeTaggedQueue() + + TimeoutExecutor.run( + timeout: 1.0, + queue: callbackQueue, + operation: { done in + // Finish well before timeout + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + done("OK") + } + }, + timeoutValue: "TIMEOUT" + ) { result in + XCTAssertEqual(result, "OK") + // Assert queue + XCTAssertEqual(DispatchQueue.getSpecific(key: self.queueKey), self.queueLabel) + exp.fulfill() + } + + wait(for: [exp], timeout: 2.0) + } + + func test_TimeoutWins_CallsCompletionWithTimeoutValue() { + let exp = expectation(description: "completion called with timeout") + let callbackQueue = makeTaggedQueue() + + TimeoutExecutor.run( + timeout: 0.2, + queue: callbackQueue, + operation: { _ in + // Complete after the timeout + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { /* never calls done */ } + }, + timeoutValue: "TIMEOUT" + ) { result in + XCTAssertEqual(result, "TIMEOUT") + // Assert queue + XCTAssertEqual(DispatchQueue.getSpecific(key: self.queueKey), self.queueLabel) + exp.fulfill() + } + + wait(for: [exp], timeout: 2.0) + } + + func test_Race_ResultAndTimeout_CompletionCalledOnce() { + let exp = expectation(description: "completion called once") + exp.expectedFulfillmentCount = 1 + + let callbackQueue = makeTaggedQueue() + var callCount = 0 + let countLock = NSLock() + + TimeoutExecutor.run( + timeout: 0.15, + queue: callbackQueue, + operation: { done in + // Schedule completion very close to timeout to create a race. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.14) { + done("OK") + } + }, + timeoutValue: "TIMEOUT" + ) { _ in + countLock.lock(); callCount += 1; countLock.unlock() + exp.fulfill() + } + + wait(for: [exp], timeout: 2.0) + XCTAssertEqual(callCount, 1, "Completion should be called exactly once") + } + + func test_CompletionsRunsOnSpecifiedQueue() { + let exp = expectation(description: "completion on specified queue") + let callbackQueue = makeTaggedQueue(label: "com.test.specific.queue") + + TimeoutExecutor.run( + timeout: 1.0, + queue: callbackQueue, + operation: { done in + DispatchQueue.global().async { done("OK") } + }, + timeoutValue: "TIMEOUT" + ) { result in + XCTAssertEqual(result, "OK") + // Verify we're on our queue + XCTAssertEqual(DispatchQueue.getSpecific(key: self.queueKey), self.queueLabel) + exp.fulfill() + } + + wait(for: [exp], timeout: 2.0) + } + + func test_NoCompletion_NoTimeoutScheduled_OperationStillRuns() { + // We can’t directly assert no timeout is scheduled, but we can ensure: + // - no completion is called (test would fail if it did) + // - the operation body executed (via a flag/expectation) + let opExp = expectation(description: "operation executed") + + TimeoutExecutor.run( + timeout: 0.1, + queue: .main, + operation: { done in + // Simulate some work and signal we ran. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + // We pass a value to `done`—it should be ignored since completion is nil. + opExp.fulfill() + done("IGNORED") + } + }, + timeoutValue: "TIMEOUT", + completion: nil // <- optional completion + ) + + // If the executor accidentally called a completion, this test would hang or require extra plumbing. + wait(for: [opExp], timeout: 1.0) + } + + func test_LongOperation_ResultAfterTimeout_Ignored() { + let exp = expectation(description: "timeout fired and late result ignored") + let callbackQueue = makeTaggedQueue() + var observedResults: [String] = [] + let lock = NSLock() + + TimeoutExecutor.run( + timeout: 0.1, + queue: callbackQueue, + operation: { done in + // Complete after timeout + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { done("LATE") } + }, + timeoutValue: "TIMEOUT" + ) { result in + lock.lock(); observedResults.append(result); lock.unlock() + exp.fulfill() + } + + wait(for: [exp], timeout: 2.0) + // Give a little extra time for any accidental second call + Thread.sleep(forTimeInterval: 0.3) + lock.lock(); defer { lock.unlock() } + XCTAssertEqual(observedResults, ["TIMEOUT"]) + } +}