From 85a7d59ae58b64924dffd1d7ab1f88f200cf6ba6 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Tue, 12 May 2026 15:10:21 +0200 Subject: [PATCH 1/9] feat: backport of v3 cache improvements --- .../TelemetryDeck/Signals/SignalCache.swift | 29 +++-- .../TelemetryDeck/Signals/SignalManager.swift | 102 +++++++++++++----- Sources/TelemetryDeck/TelemetryClient.swift | 13 +++ Sources/TelemetryDeck/TelemetryDeck.swift | 2 +- .../SignalCacheLimitTests.swift | 55 ++++++++++ .../SignalManagerBackoffTests.swift | 96 +++++++++++++++++ 6 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 Tests/TelemetryDeckTests/SignalCacheLimitTests.swift create mode 100644 Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift diff --git a/Sources/TelemetryDeck/Signals/SignalCache.swift b/Sources/TelemetryDeck/Signals/SignalCache.swift index fce4f4b..1eab4e9 100644 --- a/Sources/TelemetryDeck/Signals/SignalCache.swift +++ b/Sources/TelemetryDeck/Signals/SignalCache.swift @@ -6,12 +6,15 @@ import Foundation /// since all Signals automatically get a `receivedAt` property with a date, allowing the server to reorder them /// correctly. /// -/// Currently the cache is only in-memory. This will probably change in the near future. +/// The cache persists signals to disk via `backupCache()` and automatically restores them in `init`. +/// Signals in excess of `cacheLimit` are dropped from the front (oldest first) to keep memory and disk usage bounded. internal class SignalCache: @unchecked Sendable where T: Codable { internal var logHandler: LogHandler? private var cachedSignals: [T] = [] private let maximumNumberOfSignalsToPopAtOnce = 100 + private let cacheLimit: Int + private let cacheFileURL: URL? let queue = DispatchQueue(label: "com.telemetrydeck.SignalCache", attributes: .concurrent) @@ -26,6 +29,7 @@ internal class SignalCache: @unchecked Sendable where T: Codable { func push(_ signal: T) { queue.sync(flags: .barrier) { self.cachedSignals.append(signal) + self.trimToCacheLimitLocked() } } @@ -33,6 +37,13 @@ internal class SignalCache: @unchecked Sendable where T: Codable { func push(_ signals: [T]) { queue.sync(flags: .barrier) { self.cachedSignals.append(contentsOf: signals) + self.trimToCacheLimitLocked() + } + } + + private func trimToCacheLimitLocked() { + if cachedSignals.count > cacheLimit { + cachedSignals.removeFirst(cachedSignals.count - cacheLimit) } } @@ -50,6 +61,10 @@ internal class SignalCache: @unchecked Sendable where T: Codable { } private func fileURL() -> URL { + if let cacheFileURL { + return cacheFileURL + } + // swiftlint:disable force_try let cacheFolderURL = try! FileManager.default.url( for: .cachesDirectory, @@ -81,20 +96,22 @@ internal class SignalCache: @unchecked Sendable where T: Codable { } /// Loads any previous signal cache from disk - init(logHandler: LogHandler?) { + init(logHandler: LogHandler?, cacheLimit: Int = 10_000, fileURL: URL? = nil) { self.logHandler = logHandler + self.cacheLimit = cacheLimit + self.cacheFileURL = fileURL queue.sync { - logHandler?.log(message: "Loading Telemetry cache from: \(fileURL())") + logHandler?.log(message: "Loading Telemetry cache from: \(self.fileURL())") - if let data = try? Data(contentsOf: fileURL()) { + if let data = try? Data(contentsOf: self.fileURL()) { // Loaded cache file, now delete it to stop it being loaded multiple times - try? FileManager.default.removeItem(at: fileURL()) + try? FileManager.default.removeItem(at: self.fileURL()) // Decode the data into a new cache if let signals = try? JSONDecoder().decode([T].self, from: data) { logHandler?.log(message: "Loaded \(signals.count) signals") - self.cachedSignals = signals + self.cachedSignals = Array(signals.suffix(cacheLimit)) } } } diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index 489bdb5..ca0b7ec 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -24,19 +24,34 @@ protocol SignalManageable { } final class SignalManager: SignalManageable, @unchecked Sendable { - static let minimumSecondsToPassBetweenRequests: Double = 10 - private var signalCache: SignalCache let configuration: TelemetryManagerConfiguration private var sendTimerSource: DispatchSourceTimer? private let timerQueue = DispatchQueue(label: "com.telemetrydeck.SignalTimer", qos: .utility) + /// Number of consecutive transmission failures. + /// + /// Read and written only on `timerQueue`. + private var consecutiveFailures: Int = 0 + + #if DEBUG + /// Test-only accessor for `consecutiveFailures`. + /// + /// Uses a synchronous hop onto `timerQueue` for safe reads. + var consecutiveFailuresForTesting: Int { + timerQueue.sync { consecutiveFailures } + } + + /// Test-only accessor to push signals directly into the cache. + var signalCacheForTesting: SignalCache { signalCache } + #endif + init(configuration: TelemetryManagerConfiguration) { self.configuration = configuration // We automatically load any old signals from disk on initialisation - signalCache = SignalCache(logHandler: configuration.swiftUIPreviewMode ? nil : configuration.logHandler) + signalCache = SignalCache(logHandler: configuration.swiftUIPreviewMode ? nil : configuration.logHandler, cacheLimit: configuration.cacheLimit) // Before the app terminates, we want to save any pending signals to disk // We need to monitor different notifications for different devices. @@ -93,16 +108,26 @@ final class SignalManager: SignalManageable, @unchecked Sendable { sendCachedSignalsRepeatedly() } - /// Send any cached Signals from previous sessions now and setup a timer to repeatedly send Signals from cache in regular time intervals. + /// Fires an immediate send attempt and then starts the self-rescheduling timer. private func sendCachedSignalsRepeatedly() { attemptToSendNextBatchOfCachedSignals() + timerQueue.async { + self.consecutiveFailures = 0 + self.scheduleNextTransmission() + } + } + /// Cancels any pending timer and schedules the next one-shot transmission with exponential backoff. + /// + /// Must only be called from `timerQueue`. + private func scheduleNextTransmission() { sendTimerSource?.cancel() + + let exponent = Double(consecutiveFailures) + let delay = min(configuration.transmitInterval * pow(2, exponent), configuration.maxBackoffInterval) + let source = DispatchSource.makeTimerSource(queue: timerQueue) - source.schedule( - deadline: .now() + Self.minimumSecondsToPassBetweenRequests, - repeating: Self.minimumSecondsToPassBetweenRequests - ) + source.schedule(deadline: .now() + delay) source.setEventHandler { [weak self] in self?.attemptToSendNextBatchOfCachedSignals() } @@ -158,32 +183,51 @@ final class SignalManager: SignalManageable, @unchecked Sendable { configuration.logHandler?.log(.debug, message: "Current signal cache count: \(signalCache.count())") let queuedSignals: [SignalPostBody] = signalCache.pop() - if !queuedSignals.isEmpty { - configuration.logHandler?.log(message: "Sending \(queuedSignals.count) signals leaving a cache of \(signalCache.count()) signals") + guard !queuedSignals.isEmpty else { + handleSendSuccess() + return + } - send(queuedSignals) { [configuration, signalCache] data, response, error in + configuration.logHandler?.log(message: "Sending \(queuedSignals.count) signals leaving a cache of \(signalCache.count()) signals") - if let error = error { - configuration.logHandler?.log(.error, message: "\(error)") + send(queuedSignals) { [weak self] data, response, error in + guard let self else { return } - // The send failed, put the signal back into the queue - signalCache.push(queuedSignals) - return - } + if let error = error { + self.configuration.logHandler?.log(.error, message: "\(error)") + self.handleSendFailure(requeue: queuedSignals) + return + } - // Check for valid status code response - guard response?.statusCodeError() == nil else { - let statusError = response!.statusCodeError()! - configuration.logHandler?.log(.error, message: "\(statusError)") - // The send failed, put the signal back into the queue - signalCache.push(queuedSignals) - return - } + guard response?.statusCodeError() == nil else { + let statusError = response!.statusCodeError()! + self.configuration.logHandler?.log(.error, message: "\(statusError)") + self.handleSendFailure(requeue: queuedSignals) + return + } - if let data = data, let messageString = String(data: data, encoding: .utf8) { - configuration.logHandler?.log(.debug, message: messageString) - } + if let data = data, let messageString = String(data: data, encoding: .utf8) { + self.configuration.logHandler?.log(.debug, message: messageString) } + + self.handleSendSuccess() + } + } + + private func handleSendFailure(requeue: [SignalPostBody]) { + signalCache.push(requeue) + timerQueue.async { [weak self] in + guard let self else { return } + self.consecutiveFailures += 1 + self.scheduleNextTransmission() + } + } + + private func handleSendSuccess() { + timerQueue.async { [weak self] in + guard let self else { return } + self.consecutiveFailures = 0 + self.scheduleNextTransmission() } } } @@ -225,7 +269,7 @@ extension SignalManager { let currentCache = signalCache.pop() configuration.logHandler?.log(.debug, message: "current cache is \(currentCache.count) signals") - signalCache = SignalCache(logHandler: configuration.logHandler) + signalCache = SignalCache(logHandler: configuration.logHandler, cacheLimit: configuration.cacheLimit) signalCache.push(currentCache) sendCachedSignalsRepeatedly() diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index 35e02c5..a6ac331 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -90,6 +90,19 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { /// or debugging). If not set, the `URLSession.shared` instance will be used. public var urlSession: URLSession = URLSession.shared + /// Maximum number of signals retained in the local cache. + /// + /// When exceeded, oldest signals are dropped. Also applied when restoring signals from disk. + public var cacheLimit: Int = 10_000 + + /// Base interval in seconds between batched transmission attempts. + /// + /// Acts as the floor used as the base for exponential backoff on repeated failures. + public var transmitInterval: TimeInterval = 10 + + /// Upper bound in seconds for the exponential backoff delay between transmission attempts. + public var maxBackoffInterval: TimeInterval = 300 + @available(*, deprecated, message: "Please use the testMode property instead") public var sendSignalsInDebugConfiguration: Bool = false diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index acfddbc..c546ae8 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -222,7 +222,7 @@ public enum TelemetryDeck { let manager = TelemetryManager.shared // this check ensures that the number of requests can only double in the worst case where a developer calls this after each `send` - if Date().timeIntervalSince(manager.lastTimeImmediateSyncRequested) > SignalManager.minimumSecondsToPassBetweenRequests { + if Date().timeIntervalSince(manager.lastTimeImmediateSyncRequested) > manager.configuration.transmitInterval { manager.lastTimeImmediateSyncRequested = Date() // give the signal manager some short amount of time to process the signal that was sent right before calling sync diff --git a/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift b/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift new file mode 100644 index 0000000..a8d422e --- /dev/null +++ b/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct SignalCacheLimitTests { + + @Test + func pushSingleSignals_dropsOldestWhenLimitExceeded() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmp) } + + let cache = SignalCache(logHandler: nil, cacheLimit: 5, fileURL: tmp) + for i in 0..<7 { + cache.push("\(i)") + } + + #expect(cache.count() == 5) + + let popped = cache.pop() + #expect(popped == ["2", "3", "4", "5", "6"]) + } + + @Test + func pushBatchOfSignals_dropsOldestWhenLimitExceeded() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmp) } + + let cache = SignalCache(logHandler: nil, cacheLimit: 5, fileURL: tmp) + cache.push(["0", "1", "2", "3", "4", "5", "6"]) + + #expect(cache.count() == 5) + + let popped = cache.pop() + #expect(popped == ["2", "3", "4", "5", "6"]) + } + + @Test + func restoreFromDisk_trimsToLimit() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmp) } + + let writer = SignalCache(logHandler: nil, cacheLimit: 10_000, fileURL: tmp) + for i in 0..<10 { + writer.push("\(i)") + } + writer.backupCache() + + let reader = SignalCache(logHandler: nil, cacheLimit: 5, fileURL: tmp) + #expect(reader.count() == 5) + + let popped = reader.pop() + #expect(popped == ["5", "6", "7", "8", "9"]) + } +} diff --git a/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift new file mode 100644 index 0000000..c52539f --- /dev/null +++ b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +// MARK: - URLProtocol stub + +private final class StubURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var statusCode: Int = 500 + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: StubURLProtocol.statusCode, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +// MARK: - Tests + +@Suite(.serialized) +struct SignalManagerBackoffTests { + + private static func makeManager(statusCode: Int) -> SignalManager { + StubURLProtocol.statusCode = statusCode + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: sessionConfig) + + let config = TelemetryManagerConfiguration(appID: "test-\(UUID().uuidString)") + config.urlSession = session + config.transmitInterval = 10 + config.logHandler = nil + + return SignalManager(configuration: config) + } + + private static func makeSignal() -> SignalPostBody { + SignalPostBody( + receivedAt: Date(), + appID: "test", + clientUser: "user", + sessionID: UUID().uuidString, + type: "Test.signal", + floatValue: nil, + payload: [:], + isTestMode: "true" + ) + } + + @Test + func consecutiveFailures_incrementsOnErrorResponse() async throws { + let manager = Self.makeManager(statusCode: 500) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(manager.consecutiveFailuresForTesting == 1) + + manager.signalCacheForTesting.push(Self.makeSignal()) + manager.attemptToSendNextBatchOfCachedSignals() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(manager.consecutiveFailuresForTesting == 2) + } + + @Test + func consecutiveFailures_resetsOnSuccess() async throws { + let manager = Self.makeManager(statusCode: 500) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(manager.consecutiveFailuresForTesting == 1) + + StubURLProtocol.statusCode = 200 + manager.signalCacheForTesting.push(Self.makeSignal()) + manager.attemptToSendNextBatchOfCachedSignals() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(manager.consecutiveFailuresForTesting == 0) + } +} From 3bc01f73d743ed8cc6a7278adbfd6db199944853 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Tue, 12 May 2026 16:07:51 +0200 Subject: [PATCH 2/9] fix: possible data race when coming to foreground --- .../TelemetryDeck/Signals/SignalCache.swift | 34 +++++---- .../TelemetryDeck/Signals/SignalManager.swift | 55 +++++++++----- .../SignalCacheLimitTests.swift | 26 +++++++ .../SignalManagerEncodeFailureTests.swift | 76 +++++++++++++++++++ 4 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 Tests/TelemetryDeckTests/SignalManagerEncodeFailureTests.swift diff --git a/Sources/TelemetryDeck/Signals/SignalCache.swift b/Sources/TelemetryDeck/Signals/SignalCache.swift index 1eab4e9..1f4d2e1 100644 --- a/Sources/TelemetryDeck/Signals/SignalCache.swift +++ b/Sources/TelemetryDeck/Signals/SignalCache.swift @@ -77,6 +77,25 @@ internal class SignalCache: @unchecked Sendable where T: Codable { return cacheFolderURL.appendingPathComponent("telemetrysignalcache") } + /// Re-loads any signals previously persisted to disk and merges them with currently in-memory signals. + /// + /// Trims the merged result to `cacheLimit`. Removes the on-disk file after a successful read. + func reloadFromDisk() { + queue.sync(flags: .barrier) { + loadFromDiskLocked() + } + } + + private func loadFromDiskLocked() { + logHandler?.log(message: "Loading Telemetry cache from: \(fileURL())") + guard let data = try? Data(contentsOf: fileURL()) else { return } + try? FileManager.default.removeItem(at: fileURL()) + guard let signals = try? JSONDecoder().decode([T].self, from: data) else { return } + logHandler?.log(message: "Loaded \(signals.count) signals") + cachedSignals.append(contentsOf: signals) + trimToCacheLimitLocked() + } + /// Save the entire signal cache to disk func backupCache() { queue.sync { @@ -101,19 +120,8 @@ internal class SignalCache: @unchecked Sendable where T: Codable { self.cacheLimit = cacheLimit self.cacheFileURL = fileURL - queue.sync { - logHandler?.log(message: "Loading Telemetry cache from: \(self.fileURL())") - - if let data = try? Data(contentsOf: self.fileURL()) { - // Loaded cache file, now delete it to stop it being loaded multiple times - try? FileManager.default.removeItem(at: self.fileURL()) - - // Decode the data into a new cache - if let signals = try? JSONDecoder().decode([T].self, from: data) { - logHandler?.log(message: "Loaded \(signals.count) signals") - self.cachedSignals = Array(signals.suffix(cacheLimit)) - } - } + queue.sync(flags: .barrier) { + loadFromDiskLocked() } } } diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index ca0b7ec..935c63b 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -24,7 +24,7 @@ protocol SignalManageable { } final class SignalManager: SignalManageable, @unchecked Sendable { - private var signalCache: SignalCache + private let signalCache: SignalCache let configuration: TelemetryManagerConfiguration private var sendTimerSource: DispatchSourceTimer? @@ -45,6 +45,8 @@ final class SignalManager: SignalManageable, @unchecked Sendable { /// Test-only accessor to push signals directly into the cache. var signalCacheForTesting: SignalCache { signalCache } + + var _shouldFailNextEncode: Bool = false #endif init(configuration: TelemetryManagerConfiguration) { @@ -190,7 +192,16 @@ final class SignalManager: SignalManageable, @unchecked Sendable { configuration.logHandler?.log(message: "Sending \(queuedSignals.count) signals leaving a cache of \(signalCache.count()) signals") - send(queuedSignals) { [weak self] data, response, error in + let body: Data + do { + body = try encodeBatch(queuedSignals) + } catch { + configuration.logHandler?.log(.error, message: "Failed to encode signal batch: \(error)") + handleSendFailure(requeue: queuedSignals) + return + } + + send(body) { [weak self] data, response, error in guard let self else { return } if let error = error { @@ -259,19 +270,10 @@ extension SignalManager { #endif } - /// WatchOS doesn't have a notification before it's killed, so we have to use background/foreground - /// This means our `init()` above doesn't always run when coming back to foreground, so we have to manually - /// reload the cache. This also means we miss any signals sent during watchDidEnterForeground - /// so we merge them into the new cache. #if os(watchOS) || os(tvOS) || os(iOS) || os(visionOS) @objc func didEnterForeground() { configuration.logHandler?.log(.debug, message: #function) - - let currentCache = signalCache.pop() - configuration.logHandler?.log(.debug, message: "current cache is \(currentCache.count) signals") - signalCache = SignalCache(logHandler: configuration.logHandler, cacheLimit: configuration.cacheLimit) - signalCache.push(currentCache) - + signalCache.reloadFromDisk() sendCachedSignalsRepeatedly() } @@ -307,7 +309,24 @@ extension SignalManager { // MARK: - Comms extension SignalManager { - private func send(_ signalPostBodies: [SignalPostBody], completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) { + #if DEBUG + internal var shouldFailNextEncodeForTesting: Bool { + get { _shouldFailNextEncode } + set { _shouldFailNextEncode = newValue } + } + #endif + + internal func encodeBatch(_ bodies: [SignalPostBody]) throws -> Data { + #if DEBUG + if _shouldFailNextEncode { + _shouldFailNextEncode = false + throw TelemetryError.encodeFailed(TelemetryError.invalidEndpointUrl) + } + #endif + return try JSONEncoder.telemetryEncoder.encode(bodies) + } + + private func send(_ body: Data, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) { DispatchQueue.global(qos: .utility).async { guard let url = SignalManager.getServiceUrl(baseURL: self.configuration.apiBaseURL, namespace: self.configuration.namespace) else { self.configuration.logHandler?.log( @@ -321,14 +340,9 @@ extension SignalManager { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - - guard let body = try? JSONEncoder.telemetryEncoder.encode(signalPostBodies) else { - return - } - urlRequest.httpBody = body - if let data = urlRequest.httpBody, let messageString = String(data: data, encoding: .utf8) { + if let messageString = String(data: body, encoding: .utf8) { self.configuration.logHandler?.log(.debug, message: messageString) } @@ -428,6 +442,7 @@ private enum TelemetryError: Error { case payloadTooLarge case invalidStatusCode(statusCode: Int) case invalidEndpointUrl + case encodeFailed(Error) } extension TelemetryError: LocalizedError { @@ -443,6 +458,8 @@ extension TelemetryError: LocalizedError { return "Payload is too large (413)" case .invalidEndpointUrl: return "Invalid endpoint URL" + case .encodeFailed(let error): + return "Failed to encode signal batch: \(error)" } } } diff --git a/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift b/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift index a8d422e..0e74df0 100644 --- a/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift +++ b/Tests/TelemetryDeckTests/SignalCacheLimitTests.swift @@ -35,6 +35,32 @@ struct SignalCacheLimitTests { #expect(popped == ["2", "3", "4", "5", "6"]) } + @Test + func reloadFromDisk_mergesWithInMemorySignals() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmp) } + + let writer = SignalCache(logHandler: nil, cacheLimit: 100, fileURL: tmp) + writer.push(["a", "b", "c"]) + writer.backupCache() + + let tmp2 = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmp2) } + + let cache = SignalCache(logHandler: nil, cacheLimit: 100, fileURL: tmp2) + cache.push(["d", "e"]) + + let diskData = try JSONEncoder().encode(["a", "b", "c"]) + try diskData.write(to: tmp2) + + cache.reloadFromDisk() + + #expect(cache.count() == 5) + + let popped = cache.pop() + #expect(Set(popped) == Set(["a", "b", "c", "d", "e"])) + } + @Test func restoreFromDisk_trimsToLimit() throws { let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/TelemetryDeckTests/SignalManagerEncodeFailureTests.swift b/Tests/TelemetryDeckTests/SignalManagerEncodeFailureTests.swift new file mode 100644 index 0000000..93e6f48 --- /dev/null +++ b/Tests/TelemetryDeckTests/SignalManagerEncodeFailureTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +// MARK: - URLProtocol stub + +private final class EncodeFailureStubURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var requestReceived = false + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + EncodeFailureStubURLProtocol.requestReceived = true + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +// MARK: - Tests + +@Suite(.serialized) +struct SignalManagerEncodeFailureTests { + + private static func makeManager() -> SignalManager { + EncodeFailureStubURLProtocol.requestReceived = false + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [EncodeFailureStubURLProtocol.self] + let session = URLSession(configuration: sessionConfig) + + let config = TelemetryManagerConfiguration(appID: "test-\(UUID().uuidString)") + config.urlSession = session + config.transmitInterval = 10 + config.logHandler = nil + + return SignalManager(configuration: config) + } + + private static func makeSignal() -> SignalPostBody { + SignalPostBody( + receivedAt: Date(), + appID: "test", + clientUser: "user", + sessionID: UUID().uuidString, + type: "Test.signal", + floatValue: nil, + payload: [:], + isTestMode: "true" + ) + } + + @Test + func encodeFailure_requeuesBatchAndRearmsTimer() async throws { + let manager = Self.makeManager() + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.shouldFailNextEncodeForTesting = true + manager.attemptToSendNextBatchOfCachedSignals() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(EncodeFailureStubURLProtocol.requestReceived == false) + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } +} From d81ab03d7bdf7311b45af760fc8a03dedc326c68 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Tue, 12 May 2026 16:29:35 +0200 Subject: [PATCH 3/9] chore: fix flaky tests --- .../SignalManagerBackoffTests.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift index c52539f..f5b5952 100644 --- a/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift +++ b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift @@ -59,19 +59,30 @@ struct SignalManagerBackoffTests { ) } + private func waitForConsecutiveFailures( + _ manager: SignalManager, + toEqual expected: Int, + timeout: TimeInterval = 5 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while manager.consecutiveFailuresForTesting != expected, Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + } + @Test func consecutiveFailures_incrementsOnErrorResponse() async throws { let manager = Self.makeManager(statusCode: 500) manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await Task.sleep(nanoseconds: 300_000_000) + try await waitForConsecutiveFailures(manager, toEqual: 1) #expect(manager.consecutiveFailuresForTesting == 1) manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await Task.sleep(nanoseconds: 300_000_000) + try await waitForConsecutiveFailures(manager, toEqual: 2) #expect(manager.consecutiveFailuresForTesting == 2) } @@ -82,14 +93,14 @@ struct SignalManagerBackoffTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await Task.sleep(nanoseconds: 300_000_000) + try await waitForConsecutiveFailures(manager, toEqual: 1) #expect(manager.consecutiveFailuresForTesting == 1) StubURLProtocol.statusCode = 200 manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await Task.sleep(nanoseconds: 300_000_000) + try await waitForConsecutiveFailures(manager, toEqual: 0) #expect(manager.consecutiveFailuresForTesting == 0) } From 769102d0baac20c1050a5a619f6ea74d20543ab5 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Tue, 12 May 2026 16:36:59 +0200 Subject: [PATCH 4/9] fix: xcode version --- .github/workflows/ci.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7eaf457..3c5e2bb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,13 +25,13 @@ jobs: fail-fast: true matrix: xcode: - - "26.1.1" + - "26.5" xcodebuildCommand: - - "-sdk iphonesimulator -destination 'platform=iOS Simulator,OS=26.1,name=iPhone 17'" + - "-sdk iphonesimulator -destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17'" - "-sdk macosx -destination 'platform=macOS'" - - "-sdk xrsimulator -destination 'platform=visionOS Simulator,OS=26.1,name=Apple Vision Pro'" - - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,OS=26.1,name=Apple TV 4K (3rd generation)'" - - "-sdk watchsimulator -destination 'platform=watchOS Simulator,OS=26.1,name=Apple Watch Series 11 (46mm)'" + - "-sdk xrsimulator -destination 'platform=visionOS Simulator,OS=26.5,name=Apple Vision Pro'" + - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,OS=26.5,name=Apple TV 4K (3rd generation)'" + - "-sdk watchsimulator -destination 'platform=watchOS Simulator,OS=26.5,name=Apple Watch Series 11 (46mm)'" steps: - name: Repository checkout uses: actions/checkout@v5 From 16a0466cefa5853d3675b5de25d3229d7949c27a Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Tue, 12 May 2026 16:40:09 +0200 Subject: [PATCH 5/9] fix: xcode version --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c5e2bb..371e0ce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: fail-fast: true matrix: xcode: - - "26.5" + - "26.5.0" xcodebuildCommand: - "-sdk iphonesimulator -destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17'" - "-sdk macosx -destination 'platform=macOS'" From d357eaf2db209b9716b05605742409c68552680b Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 14 May 2026 19:31:47 +0200 Subject: [PATCH 6/9] feat: some http codes do not need to be retried --- .../TelemetryDeck/Signals/SignalManager.swift | 82 +++-- .../SignalManagerDispositionTests.swift | 279 ++++++++++++++++++ 2 files changed, 318 insertions(+), 43 deletions(-) create mode 100644 Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index 935c63b..fbed677 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -210,18 +210,24 @@ final class SignalManager: SignalManageable, @unchecked Sendable { return } - guard response?.statusCodeError() == nil else { - let statusError = response!.statusCodeError()! - self.configuration.logHandler?.log(.error, message: "\(statusError)") + let disposition = response?.disposition() ?? .retry + switch disposition { + case .success: + if let data = data, let messageString = String(data: data, encoding: .utf8) { + self.configuration.logHandler?.log(.debug, message: messageString) + } + self.handleSendSuccess() + case .drop(let reason): + self.configuration.logHandler?.log( + .error, + message: "Dropping \(queuedSignals.count) signal(s): rejected by server \(reason)" + ) + self.handleSendSuccess() + case .retry: + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + self.configuration.logHandler?.log(.debug, message: "Failed to send events (status \(code)), will try again later") self.handleSendFailure(requeue: queuedSignals) - return - } - - if let data = data, let messageString = String(data: data, encoding: .utf8) { - self.configuration.logHandler?.log(.debug, message: messageString) } - - self.handleSendSuccess() } } @@ -407,40 +413,38 @@ extension SignalManager { } } +fileprivate enum ResponseDisposition { + case success + case drop(reason: String) + case retry +} + extension URLResponse { - /// Returns the HTTP status code - fileprivate func statusCode() -> Int? { - if let httpResponse = self as? HTTPURLResponse { - return httpResponse.statusCode + fileprivate func disposition() -> ResponseDisposition { + guard let httpResponse = self as? HTTPURLResponse else { + return .retry } - return nil - } - - /// Returns an `Error` if not a valid statusCode - fileprivate func statusCodeError() -> Error? { - // Check for valid response in the 200-299 range - guard (200...299).contains(statusCode() ?? 0) else { - if statusCode() == 401 { - return TelemetryError.unauthorised - } else if statusCode() == 403 { - return TelemetryError.forbidden - } else if statusCode() == 413 { - return TelemetryError.payloadTooLarge - } else { - return TelemetryError.invalidStatusCode(statusCode: statusCode() ?? 0) - } + let code = httpResponse.statusCode + if (200...299).contains(code) { + return .success + } + switch code { + case 400: return .drop(reason: "Bad Request (400)") + case 401: return .drop(reason: "Unauthorized (401)") + case 403: return .drop(reason: "Forbidden (403)") + case 404: return .drop(reason: "Not Found (404)") + case 413: return .drop(reason: "Payload Too Large (413)") + case 422: return .drop(reason: "Unprocessable Entity (422)") + case 501: return .drop(reason: "Not Implemented (501)") + case 505: return .drop(reason: "HTTP Version Not Supported (505)") + default: return .retry } - return nil } } // MARK: - Errors private enum TelemetryError: Error { - case unauthorised - case forbidden - case payloadTooLarge - case invalidStatusCode(statusCode: Int) case invalidEndpointUrl case encodeFailed(Error) } @@ -448,14 +452,6 @@ private enum TelemetryError: Error { extension TelemetryError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidStatusCode(let statusCode): - return "Invalid status code \(statusCode)" - case .unauthorised: - return "Unauthorized (401)" - case .forbidden: - return "Forbidden (403)" - case .payloadTooLarge: - return "Payload is too large (413)" case .invalidEndpointUrl: return "Invalid endpoint URL" case .encodeFailed(let error): diff --git a/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift b/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift new file mode 100644 index 0000000..be09018 --- /dev/null +++ b/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +// MARK: - URLProtocol stub + +private final class DispositionStubURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var statusCode: Int = 200 + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: DispositionStubURLProtocol.statusCode, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +// MARK: - Tests + +@Suite(.serialized) +struct SignalManagerDispositionTests { + + private static func makeManager(statusCode: Int) -> SignalManager { + DispositionStubURLProtocol.statusCode = statusCode + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [DispositionStubURLProtocol.self] + let session = URLSession(configuration: sessionConfig) + + let config = TelemetryManagerConfiguration(appID: "test-\(UUID().uuidString)") + config.urlSession = session + config.transmitInterval = 10 + config.logHandler = nil + + return SignalManager(configuration: config) + } + + private static func makeSignal() -> SignalPostBody { + SignalPostBody( + receivedAt: Date(), + appID: "test", + clientUser: "user", + sessionID: UUID().uuidString, + type: "Test.signal", + floatValue: nil, + payload: [:], + isTestMode: "true" + ) + } + + private func waitForConsecutiveFailures( + _ manager: SignalManager, + toEqual expected: Int, + timeout: TimeInterval = 5 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while manager.consecutiveFailuresForTesting != expected, Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + } + + // MARK: Success + + @Test + func successResponse_clearsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 200) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + // MARK: Drop statuses + + @Test + func clientError400_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 400) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func clientError401_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 401) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func clientError403_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 403) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func clientError404_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 404) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func clientError413_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 413) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func clientError422_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 422) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func serverError501_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 501) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + @Test + func serverError505_dropsBatch_andResetsFailures() async throws { + let manager = Self.makeManager(statusCode: 505) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } + + // MARK: Retry statuses + + @Test + func serverError500_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 500) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + @Test + func clientError408_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 408) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + @Test + func clientError429_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 429) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + @Test + func serverError502_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 502) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + @Test + func serverError503_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 503) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + @Test + func serverError504_requeuesBatch_andIncrementsFailures() async throws { + let manager = Self.makeManager(statusCode: 504) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.signalCacheForTesting.count() == 1) + #expect(manager.consecutiveFailuresForTesting == 1) + } + + // MARK: Drop after retry + + @Test + func dropAfterRetries_resetsFailureCounter() async throws { + let manager = Self.makeManager(statusCode: 500) + manager.signalCacheForTesting.push(Self.makeSignal()) + + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 1) + + #expect(manager.consecutiveFailuresForTesting == 1) + + DispositionStubURLProtocol.statusCode = 403 + manager.signalCacheForTesting.push(Self.makeSignal()) + manager.attemptToSendNextBatchOfCachedSignals() + try await waitForConsecutiveFailures(manager, toEqual: 0) + + #expect(manager.signalCacheForTesting.count() == 0) + #expect(manager.consecutiveFailuresForTesting == 0) + } +} From 530f5765439755d8a1ee49309fd88c3f8749d795 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 14 May 2026 19:40:30 +0200 Subject: [PATCH 7/9] chore: xcode select, sdk versions --- .github/workflows/ci.yaml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 371e0ce..f28f256 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,26 +19,21 @@ jobs: - name: Lint run: make checklint test: - name: Test Xcode ${{ matrix.xcode }} - ${{ matrix.xcodebuildCommand }} + name: Test ${{ matrix.xcodebuildCommand }} runs-on: "macos-26" strategy: fail-fast: true matrix: - xcode: - - "26.5.0" xcodebuildCommand: - - "-sdk iphonesimulator -destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17'" + - "-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17'" - "-sdk macosx -destination 'platform=macOS'" - - "-sdk xrsimulator -destination 'platform=visionOS Simulator,OS=26.5,name=Apple Vision Pro'" - - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,OS=26.5,name=Apple TV 4K (3rd generation)'" - - "-sdk watchsimulator -destination 'platform=watchOS Simulator,OS=26.5,name=Apple Watch Series 11 (46mm)'" + - "-sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro'" + - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'" + - "-sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)'" steps: - name: Repository checkout - uses: actions/checkout@v5 - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.xcode }} - + uses: actions/checkout@v6 + - name: Select Xcode version + run: sudo xcode-select -s '/Applications/Xcode_26.4.1.app/Contents/Developer' - name: Build and Test run: xcodebuild test -scheme TelemetryDeck-Package ${{ matrix.xcodebuildCommand }} From 9947011125f94b43fc40daea7a03f34e9f739f48 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 14 May 2026 20:40:01 +0200 Subject: [PATCH 8/9] feat: disable linting - we longer do it for v2 --- .github/workflows/ci.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f28f256..9c63cf1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,16 +8,16 @@ on: - "*" jobs: - lint: - name: Lint Code - runs-on: ubuntu-latest - steps: - - name: Repository checkout - uses: actions/checkout@v5 - - name: Get swift version - run: swift --version - - name: Lint - run: make checklint + # lint: + # name: Lint Code + # runs-on: ubuntu-latest + # steps: + # - name: Repository checkout + # uses: actions/checkout@v5 + # - name: Get swift version + # run: swift --version + # - name: Lint + # run: make checklint test: name: Test ${{ matrix.xcodebuildCommand }} runs-on: "macos-26" From 4785589ca5ab399d210fdf22d998623d2d601100 Mon Sep 17 00:00:00 2001 From: Konstantin Kostov Date: Thu, 14 May 2026 20:40:45 +0200 Subject: [PATCH 9/9] fix: watchOS does not allow protocolClasses on urlsessionconfiguration --- .../TelemetryDeck/Signals/SignalManager.swift | 14 ++++++++ .../SignalManagerBackoffTests.swift | 6 ++++ .../SignalManagerDispositionTests.swift | 35 ++++++++++++++----- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index fbed677..d0be472 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -35,6 +35,11 @@ final class SignalManager: SignalManageable, @unchecked Sendable { /// Read and written only on `timerQueue`. private var consecutiveFailures: Int = 0 + /// Number of completed send attempts (success + failure paths). + /// + /// Read and written only on `timerQueue`. + private var sendCompletions: Int = 0 + #if DEBUG /// Test-only accessor for `consecutiveFailures`. /// @@ -43,6 +48,13 @@ final class SignalManager: SignalManageable, @unchecked Sendable { timerQueue.sync { consecutiveFailures } } + /// Test-only accessor for `sendCompletions`. + /// + /// Uses a synchronous hop onto `timerQueue` for safe reads. + var sendCompletionsForTesting: Int { + timerQueue.sync { sendCompletions } + } + /// Test-only accessor to push signals directly into the cache. var signalCacheForTesting: SignalCache { signalCache } @@ -235,6 +247,7 @@ final class SignalManager: SignalManageable, @unchecked Sendable { signalCache.push(requeue) timerQueue.async { [weak self] in guard let self else { return } + self.sendCompletions += 1 self.consecutiveFailures += 1 self.scheduleNextTransmission() } @@ -243,6 +256,7 @@ final class SignalManager: SignalManageable, @unchecked Sendable { private func handleSendSuccess() { timerQueue.async { [weak self] in guard let self else { return } + self.sendCompletions += 1 self.consecutiveFailures = 0 self.scheduleNextTransmission() } diff --git a/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift index f5b5952..cfb2790 100644 --- a/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift +++ b/Tests/TelemetryDeckTests/SignalManagerBackoffTests.swift @@ -3,6 +3,10 @@ import Testing @testable import TelemetryDeck +// URLSessionConfiguration.protocolClasses is not honored on watchOS, so these tests +// cannot intercept HTTP responses. Run on every other platform. +#if !os(watchOS) + // MARK: - URLProtocol stub private final class StubURLProtocol: URLProtocol, @unchecked Sendable { @@ -105,3 +109,5 @@ struct SignalManagerBackoffTests { #expect(manager.consecutiveFailuresForTesting == 0) } } + +#endif // !os(watchOS) diff --git a/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift b/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift index be09018..9b06cee 100644 --- a/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift +++ b/Tests/TelemetryDeckTests/SignalManagerDispositionTests.swift @@ -3,6 +3,10 @@ import Testing @testable import TelemetryDeck +// URLSessionConfiguration.protocolClasses is not honored on watchOS, so these tests +// cannot intercept HTTP responses. Run on every other platform. +#if !os(watchOS) + // MARK: - URLProtocol stub private final class DispositionStubURLProtocol: URLProtocol, @unchecked Sendable { @@ -70,6 +74,17 @@ struct SignalManagerDispositionTests { } } + private func waitForSendCompletion( + _ manager: SignalManager, + toReach expected: Int, + timeout: TimeInterval = 5 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while manager.sendCompletionsForTesting < expected, Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + } + // MARK: Success @Test @@ -78,7 +93,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -92,7 +107,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -104,7 +119,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -116,7 +131,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -128,7 +143,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -140,7 +155,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -152,7 +167,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -164,7 +179,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -176,7 +191,7 @@ struct SignalManagerDispositionTests { manager.signalCacheForTesting.push(Self.makeSignal()) manager.attemptToSendNextBatchOfCachedSignals() - try await waitForConsecutiveFailures(manager, toEqual: 0) + try await waitForSendCompletion(manager, toReach: 1) #expect(manager.signalCacheForTesting.count() == 0) #expect(manager.consecutiveFailuresForTesting == 0) @@ -277,3 +292,5 @@ struct SignalManagerDispositionTests { #expect(manager.consecutiveFailuresForTesting == 0) } } + +#endif // !os(watchOS)