From f1c20768474f2fc5309c55f6cdab9fb1f5d2d52f Mon Sep 17 00:00:00 2001 From: Marcin Polak Date: Wed, 12 Aug 2026 09:27:25 +0200 Subject: [PATCH 1/4] refactor: introduce dedicated refresh queue --- .../FeaturevisorSDK/Instance+Refresh.swift | 84 +++++++++---------- Sources/FeaturevisorSDK/Instance.swift | 3 +- 2 files changed, 41 insertions(+), 46 deletions(-) diff --git a/Sources/FeaturevisorSDK/Instance+Refresh.swift b/Sources/FeaturevisorSDK/Instance+Refresh.swift index 935f199..85e59af 100644 --- a/Sources/FeaturevisorSDK/Instance+Refresh.swift +++ b/Sources/FeaturevisorSDK/Instance+Refresh.swift @@ -52,53 +52,47 @@ extension FeaturevisorInstance { } public func startRefreshing() { - - guard datafileUrl != nil else { - logger.error("cannot start refreshing since `datafileUrl` is not provided") - return - } - - guard timer == nil else { - logger.warn("refreshing has already started") - return - } - - guard let refreshInterval else { - logger.warn("no `refreshInterval` option provided") - return - } - - DispatchQueue.global() - .async { [weak self] in - self?.timer = Timer.scheduledTimer( - withTimeInterval: TimeInterval(refreshInterval), - repeats: true - ) { _ in - self?.refresh() - } - - if let timer = self?.timer { - RunLoop.current.add(timer, forMode: .common) - RunLoop.current.run() - } - } + refreshQueue.async { [weak self] in + guard let self else { return } + + guard self.datafileUrl != nil else { + self.logger.error("cannot start refreshing since `datafileUrl` is not provided") + return + } + + guard self.refreshTimer == nil else { + self.logger.warn("refreshing has already started") + return + } + + guard let refreshInterval = self.refreshInterval else { + self.logger.warn("no `refreshInterval` option provided") + return + } + + let timer = DispatchSource.makeTimerSource(queue: self.refreshQueue) + timer.schedule(deadline: .now() + refreshInterval, repeating: refreshInterval) + timer.setEventHandler { [weak self] in + self?.refresh() + } + timer.resume() + + self.refreshTimer = timer + } } public func stopRefreshing() { - - DispatchQueue.global() - .async { [weak self] in - - guard let intervalId = self?.timer else { - self?.logger.warn("refreshing has not started yet") - return - } - - intervalId.invalidate() - self?.timer = nil - } - - logger.warn("refreshing has stopped") + refreshQueue.async { [weak self] in + guard let self else { return } + + guard let timer = self.refreshTimer else { + self.logger.warn("refreshing has not started yet") + return + } + + timer.cancel() + self.refreshTimer = nil + self.logger.warn("refreshing has stopped") + } } - } diff --git a/Sources/FeaturevisorSDK/Instance.swift b/Sources/FeaturevisorSDK/Instance.swift index 10f8b3d..92e08e7 100644 --- a/Sources/FeaturevisorSDK/Instance.swift +++ b/Sources/FeaturevisorSDK/Instance.swift @@ -185,7 +185,8 @@ public class FeaturevisorInstance { internal var stickyFeatures: StickyFeatures? // internally created - internal var timer: Timer? + internal let refreshQueue = DispatchQueue(label: "com.featurevisor.datafile.refresh") + internal var refreshTimer: DispatchSourceTimer? internal var datafileReader: DatafileReader internal var emitter: Emitter internal var statuses: Statuses From c5e0a9491f1956fa25030727c3a9cddd605887e2 Mon Sep 17 00:00:00 2001 From: Marcin Polak Date: Wed, 12 Aug 2026 10:30:22 +0200 Subject: [PATCH 2/4] Fix swift lint issues --- .../FeaturevisorSDK/Instance+Refresh.swift | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/Sources/FeaturevisorSDK/Instance+Refresh.swift b/Sources/FeaturevisorSDK/Instance+Refresh.swift index 85e59af..2d46111 100644 --- a/Sources/FeaturevisorSDK/Instance+Refresh.swift +++ b/Sources/FeaturevisorSDK/Instance+Refresh.swift @@ -52,47 +52,47 @@ extension FeaturevisorInstance { } public func startRefreshing() { - refreshQueue.async { [weak self] in - guard let self else { return } - - guard self.datafileUrl != nil else { - self.logger.error("cannot start refreshing since `datafileUrl` is not provided") - return - } - - guard self.refreshTimer == nil else { - self.logger.warn("refreshing has already started") - return - } - - guard let refreshInterval = self.refreshInterval else { - self.logger.warn("no `refreshInterval` option provided") - return - } - - let timer = DispatchSource.makeTimerSource(queue: self.refreshQueue) - timer.schedule(deadline: .now() + refreshInterval, repeating: refreshInterval) - timer.setEventHandler { [weak self] in - self?.refresh() - } - timer.resume() - - self.refreshTimer = timer - } + refreshQueue.async { [weak self] in + guard let self else { return } + + guard self.datafileUrl != nil else { + self.logger.error("cannot start refreshing since `datafileUrl` is not provided") + return + } + + guard self.refreshTimer == nil else { + self.logger.warn("refreshing has already started") + return + } + + guard let refreshInterval = self.refreshInterval else { + self.logger.warn("no `refreshInterval` option provided") + return + } + + let timer = DispatchSource.makeTimerSource(queue: self.refreshQueue) + timer.schedule(deadline: .now() + refreshInterval, repeating: refreshInterval) + timer.setEventHandler { [weak self] in + self?.refresh() + } + timer.resume() + + self.refreshTimer = timer + } } public func stopRefreshing() { - refreshQueue.async { [weak self] in - guard let self else { return } - - guard let timer = self.refreshTimer else { - self.logger.warn("refreshing has not started yet") - return - } - - timer.cancel() - self.refreshTimer = nil - self.logger.warn("refreshing has stopped") - } + refreshQueue.async { [weak self] in + guard let self else { return } + + guard let timer = self.refreshTimer else { + self.logger.warn("refreshing has not started yet") + return + } + + timer.cancel() + self.refreshTimer = nil + self.logger.warn("refreshing has stopped") + } } } From 93ca4ae1fe7789a1bc4397a9f0458f42b48d2872 Mon Sep 17 00:00:00 2001 From: Marcin Polak Date: Wed, 12 Aug 2026 10:45:32 +0200 Subject: [PATCH 3/4] Use default swift version provided by github runner --- .github/workflows/pull-request.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5e1696e..fa5333f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,6 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: swift-actions/setup-swift@v2 - with: - swift-version: "5.9.2" - name: Install swift-format tool run: brew install swift-format - name: Swift-format linting Sources directory @@ -26,4 +23,4 @@ jobs: - name: Build run: swift build -v - name: Run unit tests - run: swift test -v \ No newline at end of file + run: swift test -v From 9c9ac78a4d5938c8cbea5cd0a7c4640373bbb1c8 Mon Sep 17 00:00:00 2001 From: Marcin Polak Date: Wed, 12 Aug 2026 10:59:58 +0200 Subject: [PATCH 4/4] Update schedule test after recent changes --- Tests/FeaturevisorSDKTests/InstanceTests.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Tests/FeaturevisorSDKTests/InstanceTests.swift b/Tests/FeaturevisorSDKTests/InstanceTests.swift index 47182da..454e57e 100644 --- a/Tests/FeaturevisorSDKTests/InstanceTests.swift +++ b/Tests/FeaturevisorSDKTests/InstanceTests.swift @@ -986,10 +986,12 @@ class FeaturevisorInstanceTests: XCTestCase { func testShouldStopRefreshing() { // GIVEN - var isRefreshingStopped = false var revision = 1 let refreshInterval = 1.0 + let readyExpectation = expectation(description: "sdk_ready") + let stoppedExpectation = expectation(description: "refreshing_stopped") + MockURLProtocol.requestHandler = { request in let jsonString = "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" @@ -1007,25 +1009,27 @@ class FeaturevisorInstanceTests: XCTestCase { options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] options.refreshInterval = refreshInterval options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" + options.onReady = { _ in + readyExpectation.fulfill() + } options.logger = createLogger { level, message, details in guard case .warn = level else { return } if message.contains("refreshing has stopped") { - isRefreshingStopped = true + stoppedExpectation.fulfill() } } // WHEN let sdk = try! createInstance(options: options) - - XCTAssertEqual(isRefreshingStopped, false) + wait(for: [readyExpectation], timeout: 1.0) sdk.stopRefreshing() // THEN - XCTAssertEqual(isRefreshingStopped, true) + wait(for: [stoppedExpectation], timeout: 1.0) } func testSetDatafileByValidJSON() {