From 3184f842b9bb4fad9327225e420c3ebcf2fee06a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 09:31:32 -0700 Subject: [PATCH 01/10] fix: not multithread safe access to cancel var --- LaunchDarkly/LaunchDarkly/LDClient.swift | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 6b61a2f1..844c8999 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -385,20 +385,29 @@ public class LDClient { 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 + // Use a thread-safe way to handle cancellation/completion state + let completionQueue = DispatchQueue(label: "com.launchdarkly.ldclient.identify-completion", attributes: .concurrent) + var completed = false - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - guard !cancel else { return } + let finish: (IdentifyResult) -> Void = { result in + var shouldCall = false + completionQueue.sync(flags: .barrier) { + if !completed { + completed = true + shouldCall = true + } + } + if shouldCall { + completion(result) + } + } - cancel = true - completion(.timeout) + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + finish(.timeout) } identify(context: context, useCache: useCache) { result in - guard !cancel else { return } - - cancel = true - completion(result) + finish(result) } } From 9f4d149fc6449414115ad885874844d0f4cadd5c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 10:18:06 -0700 Subject: [PATCH 02/10] Revert "fix: not multithread safe access to cancel var" This reverts commit 3184f842b9bb4fad9327225e420c3ebcf2fee06a. --- LaunchDarkly/LaunchDarkly/LDClient.swift | 27 ++++++++---------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 844c8999..6b61a2f1 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -385,29 +385,20 @@ public class LDClient { 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) } - // Use a thread-safe way to handle cancellation/completion state - let completionQueue = DispatchQueue(label: "com.launchdarkly.ldclient.identify-completion", attributes: .concurrent) - var completed = false - - let finish: (IdentifyResult) -> Void = { result in - var shouldCall = false - completionQueue.sync(flags: .barrier) { - if !completed { - completed = true - shouldCall = true - } - } - if shouldCall { - completion(result) - } - } + var cancel = false DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - finish(.timeout) + guard !cancel else { return } + + cancel = true + completion(.timeout) } identify(context: context, useCache: useCache) { result in - finish(result) + guard !cancel else { return } + + cancel = true + completion(result) } } From fd187ab27ca6ed01cee7ae12e6d2ebe615ee4feb Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 10:19:25 -0700 Subject: [PATCH 03/10] fix for unit test (+2 squashed commits) Squashed commits: [6d854db8] TimeoutExecutor [09d316e9] added comment --- LaunchDarkly/LaunchDarkly/LDClient.swift | 54 +++++++-------- .../ServiceObjects/TimeoutExecutor.swift | 68 +++++++++++++++++++ 2 files changed, 92 insertions(+), 30 deletions(-) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 6b61a2f1..2e1baf8c 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,25 @@ 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) 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 + Self.start(serviceFactory: serviceFactory, config: config, context: context) { + let onTime = startWaitSeconds > Date().timeIntervalSince1970 - startTime + done(!onTime) } - } - } - 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..0d8f0257 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift @@ -0,0 +1,68 @@ +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 + + func finish(_ value: @autoclosure () -> T) { + var shouldCall = false + lockQueue.sync { + if !finished { + finished = true + shouldCall = true + } + } + guard shouldCall else { return } + queue.async { completion(timeoutValue()) } + } + + // Start the user operation (they can call `done` from any queue) + operation { value in + finish(value) + } + + // Timeout fallback scheduled + queue.asyncAfter(deadline: .now() + timeout) { + finish(timeoutValue()) + } + } +} From ba72c4cedcf1e310a5f64eef6fd5f8153009d317 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 14:07:07 -0700 Subject: [PATCH 04/10] just using TimeoutExecutor --- LaunchDarkly/LaunchDarkly/LDClient.swift | 24 +++++++++++++++---- .../ServiceObjects/TimeoutExecutor.swift | 22 ++++++++++------- .../LaunchDarklyTests/LDClientSpec.swift | 8 +++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 2e1baf8c..dc47bf13 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -855,20 +855,36 @@ public class LDClient { start(serviceFactory: serviceFactory, config: config, context: context) completion?(true) // offline is considered a short circuited timed out case } else { - let startTime = Date().timeIntervalSince1970 + // let startTime = Date().timeIntervalSince1970 TimeoutExecutor.run( timeout: startWaitSeconds, queue: internalCompletedQueue, operation: { done in - Self.start(serviceFactory: serviceFactory, config: config, context: context) { - let onTime = startWaitSeconds > Date().timeIntervalSince1970 - startTime - done(!onTime) + start(serviceFactory: serviceFactory, config: config, context: context) { +// let onTime = startWaitSeconds > Date().timeIntervalSince1970 - startTime + done(false) } }, timeoutValue: true, completion: completion ) + +// var completed = false +// start(serviceFactory: serviceFactory, config: config, context: context) { +// internalCompletedQueue.async { +// if startTime + startWaitSeconds > Date().timeIntervalSince1970 && !completed { +// completed = true +// completion?(false) // false for not timedOut +// } +// } +// } +// internalCompletedQueue.asyncAfter(deadline: .now() + startWaitSeconds) { +// if !completed { +// completed = true +// completion?(true) // true for timedOut +// } +// } } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift index 0d8f0257..880fa030 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift @@ -43,7 +43,8 @@ final class TimeoutExecutor { let lockQueue = DispatchQueue(label: "launchdarkly.timeout.executor.lock") var finished = false - func finish(_ value: @autoclosure () -> T) { + // Start the user operation + operation { value in var shouldCall = false lockQueue.sync { if !finished { @@ -52,17 +53,20 @@ final class TimeoutExecutor { } } guard shouldCall else { return } - queue.async { completion(timeoutValue()) } - } - - // Start the user operation (they can call `done` from any queue) - operation { value in - finish(value) + queue.async { completion(value) } } - // Timeout fallback scheduled + // Timeout fallback queue.asyncAfter(deadline: .now() + timeout) { - finish(timeoutValue()) + var shouldCall = false + lockQueue.sync { + if !finished { + finished = true + shouldCall = true + } + } + guard shouldCall else { return } + completion(timeoutValue()) } } } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 7590ae2e..ca3d679f 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -326,12 +326,20 @@ final class LDClientSpec: QuickSpec { } } context("after receiving flags") { + beforeEach { + completed = false + didTimeOut = nil + startTime = nil + completeTime = nil + } it("does complete without timeout") { + //expect(didTimeOut) == nil testContext.start(completion: startCompletion) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) expect(completed).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) } it("does complete with timeout") { + //expect(didTimeOut) == nil waitUntil(timeout: .seconds(3)) { done in testContext.start(timeOut: 5.0, timeOutCompletion: startTimeoutCompletion(done)) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) From 7a2e5ff6b4ccc0d353eb82918c8d38c0e4c81eb6 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 14:08:56 -0700 Subject: [PATCH 05/10] clean up old code --- LaunchDarkly/LaunchDarkly/LDClient.swift | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index dc47bf13..1334b8da 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -853,38 +853,20 @@ public class LDClient { 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 - - TimeoutExecutor.run( + TimeoutExecutor.run( timeout: startWaitSeconds, queue: internalCompletedQueue, operation: { done in start(serviceFactory: serviceFactory, config: config, context: context) { -// let onTime = startWaitSeconds > Date().timeIntervalSince1970 - startTime done(false) } }, timeoutValue: true, completion: completion ) - -// var completed = false -// start(serviceFactory: serviceFactory, config: config, context: context) { -// internalCompletedQueue.async { -// if startTime + startWaitSeconds > Date().timeIntervalSince1970 && !completed { -// completed = true -// completion?(false) // false for not timedOut -// } -// } -// } -// internalCompletedQueue.asyncAfter(deadline: .now() + startWaitSeconds) { -// if !completed { -// completed = true -// completion?(true) // true for timedOut -// } -// } } } From ceb598a7df4e6ab2353802fae2694cdeab94abf0 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 14:13:18 -0700 Subject: [PATCH 06/10] reverse LDClientSpec --- LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index ca3d679f..7590ae2e 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -326,20 +326,12 @@ final class LDClientSpec: QuickSpec { } } context("after receiving flags") { - beforeEach { - completed = false - didTimeOut = nil - startTime = nil - completeTime = nil - } it("does complete without timeout") { - //expect(didTimeOut) == nil testContext.start(completion: startCompletion) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) expect(completed).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) } it("does complete with timeout") { - //expect(didTimeOut) == nil waitUntil(timeout: .seconds(3)) { done in testContext.start(timeOut: 5.0, timeOutCompletion: startTimeoutCompletion(done)) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) From 73524abe6866b3825a8fe6ff2e9c7539ba1079ae Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 14:25:00 -0700 Subject: [PATCH 07/10] fixed xcode project --- LaunchDarkly.xcodeproj/project.pbxproj | 10 ++++++++++ .../ServiceObjects/TimeoutExecutor.swift | 12 ++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 62718801..5856d6e3 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -38,6 +38,10 @@ 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 */; }; 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 +433,7 @@ 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; lastKnownFileType = sourcecode.swift; path = TimeoutExecutor.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 = ""; }; @@ -887,6 +892,7 @@ 83FEF8D91F2666BF001CF12C /* ServiceObjects */ = { isa = PBXGroup; children = ( + 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */, A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */, A358D6CF2A4DD45000270C60 /* EnvironmentReporting */, 8354AC742243168800CDE602 /* Cache */, @@ -1355,6 +1361,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 +1488,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 +1516,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 */, @@ -1639,6 +1648,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 */, diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift index 880fa030..8b81f908 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/TimeoutExecutor.swift @@ -52,8 +52,10 @@ final class TimeoutExecutor { shouldCall = true } } - guard shouldCall else { return } - queue.async { completion(value) } + + if shouldCall { + queue.async { completion(value) } + } } // Timeout fallback @@ -65,8 +67,10 @@ final class TimeoutExecutor { shouldCall = true } } - guard shouldCall else { return } - completion(timeoutValue()) + + if shouldCall { + completion(timeoutValue()) + } } } } From dce19d0625e8c41599f483815fbd2c75276cdb89 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 15:01:51 -0700 Subject: [PATCH 08/10] fix project --- LaunchDarkly.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 5856d6e3..2603ca56 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -433,7 +433,7 @@ 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; lastKnownFileType = sourcecode.swift; path = TimeoutExecutor.swift; sourceTree = ""; }; + 50EE85C12EA0487F007CC662 /* TimeoutExecutor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeoutExecutor.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 = ""; }; From c83a40e9ea13b11095b3218a5775f2e78116fee3 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Oct 2025 17:34:39 -0700 Subject: [PATCH 09/10] Add unit tests --- LaunchDarkly.xcodeproj/project.pbxproj | 6 + .../ServiceObjects/TimeoutExecutorSpec.swift | 167 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 2603ca56..da0bc047 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 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 */; }; @@ -434,6 +435,7 @@ 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; 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 = ""; }; @@ -634,6 +636,7 @@ 831D8B751F72A48900ED65E8 /* ServiceObjects */ = { isa = PBXGroup; children = ( + 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */, A3047D5B2A606A0000F568E0 /* EnvironmentReporting */, B46F344025E6DB7D0078D45F /* DiagnosticReporterSpec.swift */, 83CFE7CD1F7AD81D0010544E /* EventReporterSpec.swift */, @@ -1615,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 */, @@ -2027,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)"; @@ -2037,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/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift new file mode 100644 index 00000000..af2ab11f --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift @@ -0,0 +1,167 @@ +import XCTest + +@testable import LaunchDarkly + +final class TimeoutExecutorSpec: XCTestCase { + + // Helper: 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"]) + } +} From 6b46383fcabd6708159c1cfa55ffc47b5b794657 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Oct 2025 09:13:36 -0700 Subject: [PATCH 10/10] UTF-8 encoding in project file --- LaunchDarkly.xcodeproj/project.pbxproj | 2 +- .../LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index da0bc047..aa0cc617 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -435,7 +435,7 @@ 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; lastKnownFileType = sourcecode.swift; path = TimeoutExecutorSpec.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 = ""; }; diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift index af2ab11f..355d4d2b 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/TimeoutExecutorSpec.swift @@ -4,7 +4,7 @@ import XCTest final class TimeoutExecutorSpec: XCTestCase { - // Helper: create a specific queue and tag it so we can assert where completion ran. + // 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()