diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 799020c1b..cdcd4aec8 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 3D2406222E0D90E000F91253 /* EnvironmentMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */; }; 3D2406232E0D90E000F91253 /* SdkMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406122E0D90E000F91253 /* SdkMetadata.swift */; }; 3D2406252E0D90EA00F91253 /* LDClientPluginsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406242E0D90EA00F91253 /* LDClientPluginsSpec.swift */; }; + 3D34EEEF37158657D49335BB /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; }; 3D3AB9432A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; }; 3D3AB9442A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; }; 3D3AB9452A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; }; @@ -45,6 +46,7 @@ 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 */; }; + 7185A3C30E7FFC4F725769F1 /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.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 */; }; @@ -88,6 +90,7 @@ 831AAE2D20A9E4F600B46DBA /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831AAE2B20A9E4F600B46DBA /* Throttler.swift */; }; 831AAE2E20A9E4F600B46DBA /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831AAE2B20A9E4F600B46DBA /* Throttler.swift */; }; 831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831AAE2F20A9E75D00B46DBA /* ThrottlerSpec.swift */; }; + 3F9AC0D80849C86866E03807 /* BackgroundActivitySpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BDE2A66C982198D55602DA /* BackgroundActivitySpec.swift */; }; 831D2AAF2061AAA000B4AC3C /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831D2AAE2061AAA000B4AC3C /* Thread.swift */; }; 831D8B6F1F71532300ED65E8 /* HTTPHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831D8B6E1F71532300ED65E8 /* HTTPHeaders.swift */; }; 831D8B721F71D3E700ED65E8 /* DarklyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831D8B711F71D3E700ED65E8 /* DarklyService.swift */; }; @@ -245,6 +248,7 @@ 9C4E0F306B7D2158C3E6F0A2 /* JSONWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A8C1D4E7B9053A1C4D8E6 /* JSONWriter.swift */; }; A032A73E446055D14A546CA0 /* EventJSONWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B430884DBF2E541B919C08FA /* EventJSONWriter.swift */; }; A0BAD9EE25943C43A5154E6F /* EventJSONWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 367A9182439001C3EBD68477 /* EventJSONWriterTests.swift */; }; + A20146E7AF098560627C614E /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; }; B6168D5F889EFC4C19F59135 /* JSONWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BECAB38F41B30401D3CD7EA /* JSONWriterTests.swift */; }; A3047D642A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */; }; A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */; }; @@ -269,6 +273,7 @@ A31088272837DCA900184942 /* LDContextSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088242837DCA900184942 /* LDContextSpec.swift */; }; A31088282837DCA900184942 /* ReferenceSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088252837DCA900184942 /* ReferenceSpec.swift */; }; A31088292837DCA900184942 /* KindSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31088262837DCA900184942 /* KindSpec.swift */; }; + A32FCDB22D53263851DDB727 /* BackgroundActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */; }; A33A5F7A28466D04000C29C7 /* LDContextStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A33A5F7928466D04000C29C7 /* LDContextStub.swift */; }; A3470C372B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */; }; A3470C382B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */; }; @@ -279,9 +284,13 @@ A350386F2F4F4F610032BA9F /* IdentifySeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */; }; A35038702F4F4F610032BA9F /* IdentifySeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */; }; A35038772F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; + 99686398CCB7515A18247662 /* LDClientFlush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86CB57159BC81898DE96A352 /* LDClientFlush.swift */; }; A35038782F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; + B04B36074760CCA9F48E1851 /* LDClientFlush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86CB57159BC81898DE96A352 /* LDClientFlush.swift */; }; A35038792F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; + 121B6A1A325C9A7D5EEA2EAB /* LDClientFlush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86CB57159BC81898DE96A352 /* LDClientFlush.swift */; }; A350387A2F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */; }; + 43DC6AA5DB0BE9A7B697C10F /* LDClientFlush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86CB57159BC81898DE96A352 /* LDClientFlush.swift */; }; A350387C2F4F5A380032BA9F /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350387B2F4F5A380032BA9F /* Data+Compression.swift */; }; A350387D2F4F5A380032BA9F /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350387B2F4F5A380032BA9F /* Data+Compression.swift */; }; A350387E2F4F5A380032BA9F /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = A350387B2F4F5A380032BA9F /* Data+Compression.swift */; }; @@ -512,6 +521,7 @@ 831425B0206B030100F2EF36 /* EnvironmentReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentReporter.swift; sourceTree = ""; }; 831AAE2B20A9E4F600B46DBA /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; 831AAE2F20A9E75D00B46DBA /* ThrottlerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrottlerSpec.swift; sourceTree = ""; }; + C3BDE2A66C982198D55602DA /* BackgroundActivitySpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundActivitySpec.swift; sourceTree = ""; }; 831D2AAE2061AAA000B4AC3C /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 831D8B6E1F71532300ED65E8 /* HTTPHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaders.swift; sourceTree = ""; }; 831D8B711F71D3E700ED65E8 /* DarklyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarklyService.swift; sourceTree = ""; }; @@ -579,6 +589,7 @@ 8E38AA903063090600C4C163 /* RetryStateSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStateSpec.swift; sourceTree = ""; }; 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizerSpec.swift; sourceTree = ""; }; 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizer.swift; sourceTree = ""; }; + 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundActivity.swift; sourceTree = ""; }; 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduper.swift; sourceTree = ""; }; 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduperSpec.swift; sourceTree = ""; }; 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookDecorator.swift; sourceTree = ""; }; @@ -599,6 +610,7 @@ A3470C362B7C1ACE00951CEE /* LDValueDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoder.swift; sourceTree = ""; }; A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifySeriesContext.swift; sourceTree = ""; }; A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientIdentifyHook.swift; sourceTree = ""; }; + 86CB57159BC81898DE96A352 /* LDClientFlush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientFlush.swift; sourceTree = ""; }; A350387B2F4F5A380032BA9F /* Data+Compression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Compression.swift"; sourceTree = ""; }; A35038802F4F965E0032BA9F /* LDClientIdentifyHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientIdentifyHookSpec.swift; sourceTree = ""; }; A35038822F4F96680032BA9F /* LDClientEvaluationHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientEvaluationHookSpec.swift; sourceTree = ""; }; @@ -737,6 +749,7 @@ 83383A5020460DD30024D975 /* SynchronizingErrorSpec.swift */, 837406D321F760640087B22B /* LDTimerSpec.swift */, 831AAE2F20A9E75D00B46DBA /* ThrottlerSpec.swift */, + C3BDE2A66C982198D55602DA /* BackgroundActivitySpec.swift */, 8354AC75224316C700CDE602 /* Cache */, A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */, ); @@ -812,6 +825,7 @@ isa = PBXGroup; children = ( A35038762F4F58660032BA9F /* LDClientIdentifyHook.swift */, + 86CB57159BC81898DE96A352 /* LDClientFlush.swift */, A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */, 83B6C4B51F4DE7630055351C /* LDCommon.swift */, 8354EFDC1F26380700C05156 /* LDClient.swift */, @@ -1015,6 +1029,7 @@ B430884DBF2E541B919C08FA /* EventJSONWriter.swift */, 6F2A8C1D4E7B9053A1C4D8E6 /* JSONWriter.swift */, D93B7F5F6CF5C3DE79FB4286 /* ContextEncodingCache.swift */, + 9A771B316E19ED598CD4FB0B /* BackgroundActivity.swift */, ); path = ServiceObjects; sourceTree = ""; @@ -1488,6 +1503,7 @@ C43C37E8238DF22D003C1624 /* LDEvaluationDetail.swift in Sources */, 8311884C2113ADDE00D77CB5 /* FlagChangeObserver.swift in Sources */, A35038772F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */, + 99686398CCB7515A18247662 /* LDClientFlush.swift in Sources */, A350387C2F4F5A380032BA9F /* Data+Compression.swift in Sources */, A3BA7CF62BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C3A2B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, @@ -1539,6 +1555,7 @@ 94C1C136BA8989F1B8AFE0A8 /* EventJSONWriter.swift in Sources */, 8B3D9E2F5A6C1047B2D5E9F1 /* JSONWriter.swift in Sources */, 40F4D07AD934AF976687011D /* ContextEncodingCache.swift in Sources */, + A32FCDB22D53263851DDB727 /* BackgroundActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1624,6 +1641,7 @@ A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, B4C9D43A2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, A35038792F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */, + 121B6A1A325C9A7D5EEA2EAB /* LDClientFlush.swift in Sources */, 831EF36A20655E730001C643 /* ObjcLDChangedFlag.swift in Sources */, 8E38AA8F306308E500C4C163 /* RetryState.swift in Sources */, 50EE85C22EA0487F007CC662 /* TimeoutExecutor.swift in Sources */, @@ -1631,6 +1649,7 @@ C4AF311E4DA8C3AB7040637D /* EventJSONWriter.swift in Sources */, 9C4E0F306B7D2158C3E6F0A2 /* JSONWriter.swift in Sources */, 4882B223C23B63F30BC60941 /* ContextEncodingCache.swift in Sources */, + 7185A3C30E7FFC4F725769F1 /* BackgroundActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1671,6 +1690,7 @@ 8358F25E1F474E5900ECE1AF /* LDChangedFlag.swift in Sources */, 83D559741FD87CC9002D10C8 /* KeyedValueCache.swift in Sources */, A35038782F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */, + B04B36074760CCA9F48E1851 /* LDClientFlush.swift in Sources */, A350387D2F4F5A380032BA9F /* Data+Compression.swift in Sources */, A3BA7CF32BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C372B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, @@ -1722,6 +1742,7 @@ 9BE9637664F4E97681CFCD45 /* EventJSONWriter.swift in Sources */, AD5F10417C8E3269D4F701B3 /* JSONWriter.swift in Sources */, D7A500FCA04E3412F931003F /* ContextEncodingCache.swift in Sources */, + A20146E7AF098560627C614E /* BackgroundActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1744,6 +1765,7 @@ A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */, 83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */, 831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */, + 3F9AC0D80849C86866E03807 /* BackgroundActivitySpec.swift in Sources */, 832D68AC224B3321005F052A /* CacheConverterSpec.swift in Sources */, 838F96741FB9F024009CFC45 /* LDClientSpec.swift in Sources */, 83A0E6B1203B557F00224298 /* FeatureFlagSpec.swift in Sources */, @@ -1827,6 +1849,7 @@ A358D6F02A4DE9EB00270C60 /* WatchOSEnvironmentReporter.swift in Sources */, 831AAE2D20A9E4F600B46DBA /* Throttler.swift in Sources */, A350387A2F4F58660032BA9F /* LDClientIdentifyHook.swift in Sources */, + 43DC6AA5DB0BE9A7B697C10F /* LDClientFlush.swift in Sources */, A350387E2F4F5A380032BA9F /* Data+Compression.swift in Sources */, A3BA7CF42BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C382B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, @@ -1878,6 +1901,7 @@ A032A73E446055D14A546CA0 /* EventJSONWriter.swift in Sources */, BE6011528D9F4370E50812C4 /* JSONWriter.swift in Sources */, BAE0B4F45562EBF4AC786B5D /* ContextEncodingCache.swift in Sources */, + 3D34EEEF37158657D49335BB /* BackgroundActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LaunchDarkly/GeneratedCode/mocks.generated.swift b/LaunchDarkly/GeneratedCode/mocks.generated.swift index 34916e424..47bd09180 100644 --- a/LaunchDarkly/GeneratedCode/mocks.generated.swift +++ b/LaunchDarkly/GeneratedCode/mocks.generated.swift @@ -221,6 +221,15 @@ final class EventReportingMock: EventReporting { flushReceivedCompletion = completion try! flushCallback?() } + + var flushReportingOutcomeCallCount = 0 + var flushReportingOutcomeCallback: (() throws -> Void)? + var flushReportingOutcomeReceivedCompletion: (FlushOutcomeClosure)? + func flushReportingOutcome(completion: @escaping FlushOutcomeClosure) { + flushReportingOutcomeCallCount += 1 + flushReportingOutcomeReceivedCompletion = completion + try! flushReportingOutcomeCallback?() + } } // MARK: - FeatureFlagCachingMock @@ -437,7 +446,7 @@ final class ThrottlingMock: Throttling { var runThrottledCallCount = 0 var runThrottledCallback: (() throws -> Void)? - var runThrottledReceivedRunClosure: RunClosure? + var runThrottledReceivedRunClosure: (RunClosure)? func runThrottled(_ runClosure: @escaping RunClosure) { runThrottledCallCount += 1 runThrottledReceivedRunClosure = runClosure diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 5cb3bed93..d95854f42 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -40,11 +40,11 @@ public class LDClient { // MARK: - State Controls and Indicators - private static var instances: [String: LDClient]? - private static let instancesQueue = DispatchQueue(label: "com.launchdarkly.LDClient.instancesQueue") + static var instances: [String: LDClient]? + static let instancesQueue = DispatchQueue(label: "com.launchdarkly.LDClient.instancesQueue") // If the SDK is provided a timeout value that exceeds this value, a warning will be logged. - private static let longTimeoutInterval: TimeInterval = 15 + static let longTimeoutInterval: TimeInterval = 15 /** Reports the online/offline state of the LDClient. @@ -263,6 +263,23 @@ public class LDClient { @objc private func didEnterBackground() { os_log("%s", log: config.logger, type: .debug, typeName(and: #function)) + // A backgrounded process is suspended once idle, so deliver inside an activity assertion. Only where suspension + // is real: macOS posts this notification whenever the application loses focus, and never suspends it for that. + #if os(iOS) || os(tvOS) + backgroundDelivery.run { [weak self] finished in + guard let self = self + else { + finished() + return + } + self.eventReporter.flushReportingOutcome { delivered in + if !delivered { + os_log("%s events did not reach LaunchDarkly before suspension", log: self.config.logger, type: .debug, self.typeName(and: #function)) + } + finished() + } + } + #endif Thread.performOnMain { runMode = .background } @@ -275,6 +292,9 @@ public class LDClient { } } + /// One assertion per client, however many times the application is backgrounded while a delivery is running. + private let backgroundDelivery: BackgroundActivity + let config: LDConfig /// Identifies this client's environment to a hook, without handing it the mobile key that identifies the /// environment to LaunchDarkly. Hashed once here rather than per evaluation, because a deduping hook asks for it on @@ -786,23 +806,6 @@ public class LDClient { plugin.register(client: self, metadata: environmentMetadata) } - /** - Tells the SDK to immediately send any currently queued events to LaunchDarkly. - - There should not normally be a need to call this function. While online, the LDClient automatically reports events - on an interval defined by `LDConfig.eventFlushInterval`. Note that this function does not block until events are - sent, it only triggers a background task to send events immediately. - */ - public func flush() { - LDClient.instancesQueue.sync(flags: .barrier) { - LDClient.instances?.forEach { $1.internalFlush() } - } - } - - private func internalFlush() { - eventReporter.flush(completion: nil) - } - /// Static, and over a logger rather than a client, so that the event reporter can be given it while the client /// that owns the reporter is still being built. private static func logEventSyncComplete(_ result: SynchronizingError?, _ logger: OSLog) { @@ -1027,6 +1030,7 @@ public class LDClient { throttler = self.serviceFactory.makeThrottler(environmentReporter: environmentReporter) config = configuration + backgroundDelivery = BackgroundActivity(reason: "LaunchDarkly event delivery", logger: configuration.logger) context = startContext ?? LDContext() if config.autoEnvAttributes { @@ -1089,6 +1093,16 @@ public class LDClient { extension LDClient: TypeIdentifying { } +private extension LDClient { + func onEventSyncComplete(result: SynchronizingError?) { + if let synchronizingError = result { + os_log("%s result: %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: synchronizingError)) + } else { + os_log("%s result: success", log: config.logger, type: .debug, typeName(and: #function)) + } + } +} + #if DEBUG extension LDClient { func setRunMode(_ runMode: LDClientRunMode) { diff --git a/LaunchDarkly/LaunchDarkly/LDClientFlush.swift b/LaunchDarkly/LaunchDarkly/LDClientFlush.swift new file mode 100644 index 000000000..d1bff28db --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/LDClientFlush.swift @@ -0,0 +1,156 @@ +import Foundation +import OSLog + +// MARK: - Flush +extension LDClient { + /** + Tells the SDK to immediately send any currently queued events to LaunchDarkly. + + There should not normally be a need to call this function. While online, the LDClient automatically reports events + on an interval defined by `LDConfig.eventFlushInterval`. Note that this function does not block until events are + sent, it only triggers a background task to send events immediately. + + Because it does not wait, it cannot report how the delivery went. Use `flush(completion:)` where that matters + but blocking does not suit, or `flushAndWait` where blocking is acceptable. + */ + public func flush() { + LDClient.instancesQueue.sync(flags: .barrier) { + LDClient.instances?.forEach { $1.internalFlush() } + } + } + + /** + Sends any currently queued events to LaunchDarkly and reports how the delivery went. + + Like `flushAndWait`, but without blocking: the completion runs once every environment has answered. There is no + timeout, because a delivery always ends, by its own request timeouts if nothing else. This is the one to reach + for from a Swift concurrency task, where blocking is not safe. + + - parameter completion: Given `true` if LaunchDarkly accepted the events, or there were none to send, and + `false` if the client is offline or closed, or any of the events were lost. See `flushAndWait` for what being + lost covers. Runs once, on a background queue that is neither the caller's thread nor the one recorded events + wait on, so it is free to record or flush again. + */ + public func flush(completion: @escaping (Bool) -> Void) { + let clients = instancesIncludingThisClient() + guard !clients.isEmpty + else { + os_log("%s called on a closed client", log: config.logger, type: .debug, self.typeName(and: #function)) + completion(false) + return + } + + let lock = UnfairLock() + /// Only to be used while holding `lock`: each environment answers from whichever thread its delivery ended on. + var delivered = true + var outstanding = clients.count + + for client in clients { + client.eventReporter.flushReportingOutcome { result in + lock.lock() + delivered = delivered && result + outstanding -= 1 + // Nil until the last environment answers, so the completion runs once. A reporter answering twice + // takes the count past zero rather than back to it, which cannot match again. + let outcome = outstanding == 0 ? delivered : nil + lock.unlock() + + if let outcome { + completion(outcome) + } + } + } + } + + /// Every environment's client, or none where this client is no longer one of them. + private func instancesIncludingThisClient() -> [LDClient] { + let clients = LDClient.instancesQueue.sync { LDClient.instances.map { Array($0.values) } } ?? [] + return clients.contains { $0 === self } ? clients : [] + } + + /** + Sends any currently queued events to LaunchDarkly and waits up to `timeout` seconds for the result. + + The timeout bounds the wait, not the delivery: an in-flight request is left to finish. Multiple environments + deliver at the same time and share the one budget, so the call takes no longer for several of them than for one. + + A delivery that definitively failed is reported as a failure. Some other LaunchDarkly SDKs report that as a + success, on the grounds that the attempt is over, so an expectation carried from another platform may not hold + here. + + This blocks the thread it is called on until the answer is in or the budget runs out. Where the answer is not + needed, `flush` queues the same delivery and returns at once; where it is needed but blocking does not suit, + `flush(completion:)` reports it without waiting. + + Do not call this from a Swift concurrency task. Blocking there holds a thread of the cooperative pool, which + cannot grow to replace it, and enough of them will stall every other task in the process. Use + `flush(completion:)` from a task, wrapped in a continuation where you want to await it. + + This is not a crash-time mechanism. It is safe to call from the main thread, but keep the budget well below + 15 seconds there. + + - parameter timeout: How long to wait, in seconds. Zero or less does not wait, and so reports `false`. There is + no upper limit: a delivery always ends, by its own request timeouts if nothing else, so a very long timeout + waits for that. + - returns: `true` if LaunchDarkly accepted the events, or there were none to send. `false` if the timeout expired + first, the client is offline or closed, or any of the events were lost: refused by LaunchDarkly, still failing + after one retry, or unable to be serialized. Lost events are not kept for a later flush, so calling this again + does not resend them. A refusal also stops event delivery until the client is next set online, and until then + this returns `false` even with nothing to send. A `false` because the timeout expired does not mean the events + were not sent: the delivery is left running, and may still arrive. + */ + @discardableResult + public func flushAndWait(timeout: TimeInterval) -> Bool { + if timeout > LDClient.longTimeoutInterval { + os_log("%s LDClient.flushAndWait 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) + } + + let clients = instancesIncludingThisClient() + guard !clients.isEmpty + else { + // Closed, or replaced by a later start, or caught while a start is still filling the instances in. None of + // them can deliver this client's events, and saying otherwise would tell the caller they were safe. + os_log("%s called on a closed client", log: config.logger, type: .debug, self.typeName(and: #function)) + return false + } + let deadline = DispatchTime.now() + max(0, timeout) + // Counts answers rather than tracking each delivery, so that a reporter answering twice costs a spare permit + // no one reads. A DispatchGroup would fit as well, but its `leave` traps without a matching `enter`, which + // turns a broken reporter into a crash in the application. + let answered = DispatchSemaphore(value: 0) + let lock = UnfairLock() + /// Only to be used while holding `lock`: each environment answers from whichever thread its delivery ended on. + var delivered = true + + // Every environment is started before any of them is waited on. Each has its own event reporter, so waiting on + // one before starting the next would spend the caller's budget on deliveries that could have been running all + // along, and leave the last environment with none of it. + for client in clients { + client.eventReporter.flushReportingOutcome { result in + lock.lock() + delivered = delivered && result + lock.unlock() + answered.signal() + } + } + + // Every wait is against the one deadline rather than a fresh copy of it, so the timeout the caller asked for + // is the time this call can take however many environments there are. A delivery still running when the wait + // gives up is left to finish rather than canceled: its events are already in flight, so abandoning it would + // only make losing them certain. + for _ in clients { + guard answered.wait(timeout: deadline) == .success + else { return false } + } + + // The waits count answers rather than environments, so a reporter answering twice can end them while another + // environment's answer is still being written. + lock.lock() + defer { lock.unlock() } + return delivered + } + + func internalFlush() { + eventReporter.flush(completion: nil) + } +} diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 7d980c859..e49b7ca27 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -189,7 +189,12 @@ final class DarklyService: DarklyServiceProvider { // MARK: Publish Events func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?) { - guard hasMobileKey(#function) else { return } + guard hasMobileKey(#function) + else { + // Answered rather than dropped: the reporter holds every later delivery until this one answers. + completion?((nil, nil, URLError(.userAuthenticationRequired), nil)) + return + } let url = config.eventsUrl.appendingPathComponent(EventRequestPath.bulk) let headers = [HTTPHeaders.HeaderKey.eventPayloadIDHeader: payloadId].merging(httpHeaders.eventRequestHeaders) { $1 } doPublish(url: url, headers: headers, body: eventData, completion: completion) diff --git a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift index 1e8a2da23..46d386954 100644 --- a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift +++ b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift @@ -534,11 +534,58 @@ public final class ObjcLDClient: NSObject { There should not normally be a need to call this function. While online, the LDClient automatically reports events on an interval defined by `LDConfig.eventFlushInterval`. Note that this function does not block until events are sent, it only triggers a background task to send events immediately. + + Because it does not wait, it cannot report how the delivery went. Use `flushWithCompletion:` where that matters + but blocking does not suit, or `flushAndWait` where blocking is acceptable. */ @objc public func flush() { ldClient.flush() } + /** + Sends any currently queued events to LaunchDarkly and reports how the delivery went. + + Like `flushAndWait`, but without blocking: the completion runs once every environment has answered. There is no + timeout, because a delivery always ends, by its own request timeouts if nothing else. + + - parameter completion: Given `YES` if LaunchDarkly accepted the events, or there were none to send, and `NO` if + the client is offline or closed, or any of the events were lost. See `flushAndWait` for what being lost + covers. Runs once, on a background queue that is not the caller's thread. + */ + @objc public func flush(completion: @escaping (Bool) -> Void) { + ldClient.flush(completion: completion) + } + + /** + Sends any currently queued events to LaunchDarkly and waits up to `timeout` seconds for the result. + + The timeout bounds the wait, not the delivery: an in-flight request is left to finish. Multiple environments + deliver at the same time and share the one budget, so the call takes no longer for several of them than for one. + + A delivery that definitively failed is reported as a failure. Some other LaunchDarkly SDKs report that as a + success, on the grounds that the attempt is over, so an expectation carried from another platform may not hold + here. + + This blocks the thread it is called on until the answer is in or the budget runs out, so where the answer is + not needed, `flush` queues the same delivery and returns at once. + + This is not a crash-time mechanism. It is safe to call from the main thread, but keep the budget well below + 15 seconds there. + + - parameter timeout: How long to wait, in seconds. Zero or less does not wait, and so reports `NO`. There is + no upper limit: a delivery always ends, by its own request timeouts if nothing else, so a very long timeout + waits for that. + - returns: `YES` if LaunchDarkly accepted the events, or there were none to send. `NO` if the timeout expired + first, the client is offline or closed, or any of the events were lost: refused by LaunchDarkly, still failing + after one retry, or unable to be serialized. Lost events are not kept for a later flush, so calling this again + does not resend them. A refusal also stops event delivery until the client is next set online, and until then + this returns `NO` even with nothing to send. A `NO` because the timeout expired does not mean the events were + not sent: the delivery is left running, and may still arrive. + */ + @objc public func flushAndWait(timeout: TimeInterval) -> Bool { + ldClient.flushAndWait(timeout: timeout) + } + /** Starts the LDClient using the passed in `config` & `context`. Call this before requesting feature flag values. The LDClient will not go online until you call this method. Starting the LDClient means setting the `config` & `context`, setting the client online if `config.startOnline` is true (the default setting), and starting event recording. The client app must start the LDClient before it will report feature flag values. If a client does not call `start`, no methods will work. diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/BackgroundActivity.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/BackgroundActivity.swift new file mode 100644 index 000000000..eda2bb543 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/BackgroundActivity.swift @@ -0,0 +1,110 @@ +import Foundation +import OSLog + +/// Holds a background activity assertion so a backgrounded process is not suspended mid-delivery. +/// +/// Uses `ProcessInfo.performExpiringActivity` because the framework is extension-safe and cannot use +/// `UIApplication.beginBackgroundTask`. The system may refuse or end the assertion early. +/// +/// Holds at most one assertion at a time. Each blocks a system thread until its work finishes, so a burst of +/// backgroundings would otherwise hold one thread each. A request arriving while one is held does not start another: +/// the held assertion runs the work once more after its current pass, which covers whatever was recorded since that +/// pass began. +final class BackgroundActivity { + typealias Work = (_ finished: @escaping () -> Void) -> Void + typealias Assertion = (_ reason: String, _ whileHeld: @escaping Work) -> Void + /// What grants the assertion, so a test can refuse one, or expire one, without the system's cooperation. + typealias ExpiringActivity = (_ reason: String, _ whileGranted: @escaping (_ expired: Bool) -> Void) -> Void + + /// Upper bound for work that never calls `finished`. + private static let maximumDuration: TimeInterval = 30 + + private let reason: String + private let assertion: Assertion + private let lock = UnfairLock() + /// Only to be used while holding `lock`. + private var isHeld = false + /// The work to run again before the held assertion is released. Only to be used while holding `lock`. + private var rerun: Work? + + init(reason: String, assertion: @escaping Assertion) { + self.reason = reason + self.assertion = assertion + } + + convenience init(reason: String, logger: OSLog) { + self.init(reason: reason, assertion: BackgroundActivity.processAssertion(logger: logger)) + } + + /// Runs `work` under an assertion released when `work` calls `finished`, the system expires it, or + /// `maximumDuration` passes. + func run(_ work: @escaping Work) { + lock.lock() + guard !isHeld + else { + rerun = work + lock.unlock() + return + } + isHeld = true + lock.unlock() + + assertion(reason) { finished in + self.runHoldingAssertion(work, finished) + } + } + + private func runHoldingAssertion(_ work: @escaping Work, _ finished: @escaping () -> Void) { + work { + self.lock.lock() + guard let next = self.rerun + else { + self.isHeld = false + self.lock.unlock() + finished() + return + } + self.rerun = nil + self.lock.unlock() + self.runHoldingAssertion(next, finished) + } + } + + /// An assertion from the process, over an injectable system call and cap so that a test can drive both. + /// + /// Without assertions on this platform, `whileHeld` runs on the calling thread. + static func processAssertion(logger: OSLog, + perform: @escaping ExpiringActivity = BackgroundActivity.performExpiringActivity, + maximumDuration: TimeInterval = BackgroundActivity.maximumDuration) -> Assertion { + { reason, whileHeld in + #if os(iOS) || os(tvOS) || os(watchOS) + let finished = DispatchSemaphore(value: 0) + // The work does not wait on the assertion, and does not run inside it. The system refuses one outright + // under the very resource pressure this is here for, and a delivery it may suspend is better than no + // delivery. + perform(reason) { expired in + guard !expired + else { + // The system taking the assertion back, or declining to give one. Nothing should stay blocked on + // it, and the work goes ahead either way. + os_log("%s unprotected by a background assertion: %s", + log: logger, type: .debug, String(describing: BackgroundActivity.self), reason) + finished.signal() + return + } + // The assertion lasts as long as this block, which runs on a system queue, not the caller's thread. + _ = finished.wait(timeout: .now() + maximumDuration) + } + whileHeld { finished.signal() } + #else + whileHeld {} + #endif + } + } + + private static func performExpiringActivity(_ reason: String, _ whileGranted: @escaping (Bool) -> Void) { + #if os(iOS) || os(tvOS) || os(watchOS) + ProcessInfo.processInfo.performExpiringActivity(withReason: reason, using: whileGranted) + #endif + } +} diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift index 45628b887..451c7edc5 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift @@ -2,6 +2,8 @@ import Foundation import OSLog typealias EventSyncCompleteClosure = ((SynchronizingError?) -> Void) +/// Reports whether LaunchDarkly accepted every event a flush covered. +typealias FlushOutcomeClosure = (Bool) -> Void // sourcery: autoMockable protocol EventReporting { // sourcery: defaultMockValue = false @@ -13,6 +15,19 @@ protocol EventReporting { // swiftlint:disable:next function_parameter_count func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: LDValue, defaultValue: LDValue, featureFlag: FeatureFlag?, context: LDContext, includeReason: Bool) func flush(completion: CompletionClosure?) + + /// Like `flush`. Reports `true` if LaunchDarkly accepted the events, or there were none to send. Reports `false` if + /// the reporter is offline, or any of the events were lost: refused, still failing after the retry, or unable to + /// be serialized. + /// + /// Flushes arriving faster than a delivery completes join the one that is queued, since a delivery that has not + /// started yet will send their events too. A delivery that has already started is not joined, so an answer is + /// always about the batch the caller's own delivery took, never one an earlier delivery lost. + /// + /// The completion, like `flush`'s, runs on a background queue, and never on the reporter's own: a completion is + /// free to record events or flush again without waiting on the queue it is running on. It is not the caller's + /// thread either, and two completions may run at once, so anything it touches needs to expect that. + func flushReportingOutcome(completion: @escaping FlushOutcomeClosure) } class NullEventReporter: EventReporting { @@ -28,6 +43,10 @@ class NullEventReporter: EventReporting { func flush(completion: CompletionClosure?) { completion?() } + + func flushReportingOutcome(completion: @escaping FlushOutcomeClosure) { + completion(true) + } } class EventReporter: EventReporting { @@ -45,6 +64,17 @@ class EventReporter: EventReporting { private(set) var eventStore: [Event] = [] private(set) var contextSummarizer: ContextSummarizer + /// Guards `queuedDelivery`. Taken on a caller's thread and on `eventQueue`, and never held across either. + private let deliveryLock = UnfairLock() + /// Callers waiting on a delivery that is queued and has not taken the event store yet, or nil where none is + /// queued. + private var queuedDelivery: [FlushOutcomeClosure]? + + #if DEBUG + /// How many deliveries have started, which is fewer than the flushes asked for where they were joined. + private var deliveryRunCount = 0 + #endif + private var timerQueue = DispatchQueue(label: "com.launchdarkly.EventReporter.timerQueue") private var eventReportTimer: TimeResponding? var isReportingActive: Bool { eventReportTimer != nil } @@ -106,7 +136,7 @@ class EventReporter: EventReporting { private func startReporting() { guard eventReportTimer == nil else { return } - eventReportTimer = LDTimer(withTimeInterval: service.config.eventFlushInterval, fireQueue: eventQueue, execute: reportEvents) + eventReportTimer = LDTimer(withTimeInterval: service.config.eventFlushInterval, fireQueue: eventQueue, execute: queueScheduledDelivery) } private func stopReporting() { @@ -115,21 +145,74 @@ class EventReporter: EventReporting { } func flush(completion: CompletionClosure?) { - eventQueue.async { - self.reportEvents(completion: completion) + // Nil stays nil, so a flush nobody waits on joins a queued delivery without adding to its waiters. + queueDelivery(completion.map { done in { _ in done() } }) + } + + func flushReportingOutcome(completion: @escaping FlushOutcomeClosure) { + queueDelivery(completion) + } + + private func queueScheduledDelivery() { + queueDelivery(nil) + } + + /// Queues a delivery, or joins one that is queued and has not started. + /// + /// A delivery that has not started yet will send everything recorded before it starts, which includes this + /// caller's events, so joining it is as good as queueing another. Without this, flushes arriving faster than a + /// post completes each queue their own, and the one that matters -- the flush at shutdown -- waits behind all of + /// them. + /// + /// A delivery that has already started is not joined. It has taken the event store, so it cannot speak for + /// anything recorded since, and holding later deliveries back until it answers would leave the store growing + /// through a retry while nothing drained it. + private func queueDelivery(_ completion: FlushOutcomeClosure?) { + deliveryLock.lock() + if queuedDelivery != nil { + if let completion { + queuedDelivery?.append(completion) + } + deliveryLock.unlock() + return } + queuedDelivery = completion.map { [$0] } ?? [] + deliveryLock.unlock() + + eventQueue.async { self.runDelivery() } } - private func reportEvents() { - reportEvents(completion: nil) + private func runDelivery() { + // Requests arriving from here on need a delivery of their own: this one is about to take the event store, and + // what it takes is all it can speak for. + deliveryLock.lock() + let waiters = queuedDelivery ?? [] + queuedDelivery = nil + #if DEBUG + deliveryRunCount += 1 + #endif + deliveryLock.unlock() + + reportEvents { delivered in + guard !waiters.isEmpty + else { return } + // Answered away from `eventQueue`, and from whichever thread the delivery ended on, so that every + // completion reaches its caller the same way. A completion is free to record or flush again: it is never + // running on the queue those wait for. + DispatchQueue.global().async { + for waiter in waiters { + waiter(delivered) + } + } + } } - private func reportEvents(completion: CompletionClosure?) { + private func reportEvents(completion: FlushOutcomeClosure?) { guard isOnline else { os_log("%s aborted. EventReporter is offline", log: service.config.logger, type: .debug, typeName(and: #function)) reportSyncComplete(.isOffline) - completion?() + completion?(false) return } @@ -146,7 +229,7 @@ class EventReporter: EventReporting { else { os_log("%s aborted. Event store is empty", log: service.config.logger, type: .debug, typeName(and: #function)) reportSyncComplete(nil) - completion?() + completion?(true) return } @@ -162,38 +245,51 @@ class EventReporter: EventReporting { } } - private func publish(_ events: [Event], _ payloadId: String, _ completion: CompletionClosure?) { - guard let eventData = encode(events) + private func publish(_ events: [Event], _ payloadId: String, _ completion: FlushOutcomeClosure?) { + guard let (eventData, isComplete) = encode(events) else { os_log("%s Failed to serialize event(s) for publication: %s", log: service.config.logger, type: .error, typeName(and: #function), String(describing: events)) - completion?() + // Encoding is deterministic, so no retry would succeed; the events are lost. + completion?(false) return } self.service.publishEventData(eventData, payloadId) { response in - let shouldRetry = self.processEventResponse(sentEvents: events.count, response: response.urlResponse as? HTTPURLResponse, error: response.error, isRetry: false) - if shouldRetry { + switch self.processEventResponse(sentEvents: events.count, response: response.urlResponse as? HTTPURLResponse, error: response.error, isRetry: false) { + case .accepted: + completion?(isComplete) + case .refused, .dropped: + completion?(false) + case .retryable: os_log("%s Retrying event post after delay.", log: self.service.config.logger, type: .debug, self.typeName(and: #function)) DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 1.0) { self.service.publishEventData(eventData, payloadId) { response in - _ = self.processEventResponse(sentEvents: events.count, response: response.urlResponse as? HTTPURLResponse, error: response.error, isRetry: true) - completion?() + let outcome = self.processEventResponse(sentEvents: events.count, response: response.urlResponse as? HTTPURLResponse, error: response.error, isRetry: true) + completion?(outcome == .accepted && isComplete) } } - } else { - completion?() } } } + private enum DeliveryOutcome { + case accepted + /// Refused with a status that no retry would change. The events are dropped. + case refused + case retryable + /// Failed with no retry left. + case dropped + } + /// Encodes a run of events as the array the events endpoint takes. /// /// If the run cannot be encoded as a whole, each event is retried on its own. Events that fail are dropped rather - /// than put back, because encoding is deterministic and they would fail every later flush too. - private func encode(_ events: [Event]) -> Data? { + /// than put back, because encoding is deterministic and they would fail every later flush too. `isComplete` is + /// false when any were dropped. + private func encode(_ events: [Event]) -> (data: Data, isComplete: Bool)? { guard encoding != .codable else { if let data = try? encoder.encode(events) { - return data + return (data, true) } return encodeSkippingFailures(events, using: { try encoder.encode($0) }) } @@ -206,7 +302,7 @@ class EventReporter: EventReporting { }) } - private func encodeSkippingFailures(_ events: [Event], using encodeOne: (Event) throws -> Data) -> Data? { + private func encodeSkippingFailures(_ events: [Event], using encodeOne: (Event) throws -> Data) -> (data: Data, isComplete: Bool)? { var payload = Data([UInt8(ascii: "[")]) var written = 0 for event in events { @@ -230,14 +326,14 @@ class EventReporter: EventReporting { guard written > 0 else { return nil } payload.append(UInt8(ascii: "]")) - return payload + return (payload, written == events.count) } private enum EventEncodingError: Error { case handWritten } - private func processEventResponse(sentEvents: Int, response: HTTPURLResponse?, error: Error?, isRetry: Bool) -> Bool { + private func processEventResponse(sentEvents: Int, response: HTTPURLResponse?, error: Error?, isRetry: Bool) -> DeliveryOutcome { if error == nil && (200..<300).contains(response?.statusCode ?? 0) { let serverTime = response?.headerDate ?? self.lastEventResponseDate if serverTime > self.lastEventResponseDate { @@ -246,14 +342,14 @@ class EventReporter: EventReporting { os_log("%s Completed sending %d event(s)", log: service.config.logger, type: .debug, typeName(and: #function), sentEvents) self.reportSyncComplete(nil) - return false + return .accepted } if let statusCode = response?.statusCode, HTTPURLResponse.StatusCodes.isTerminalStatusCode(statusCode) { os_log("%s dropping events due to non-retriable response: %s", log: service.config.logger, type: .debug, typeName(and: #function), String(describing: response)) isOnline = false self.reportSyncComplete(.response(response)) - return false + return .refused } os_log("%s Sending events failed with error: %s response: %s", log: service.config.logger, type: .debug, typeName(and: #function), String(describing: error), String(describing: response)) @@ -265,10 +361,10 @@ class EventReporter: EventReporting { } else { reportSyncComplete(.response(response)) } - return false + return .dropped } - return true + return .retryable } private func reportSyncComplete(_ result: SynchronizingError?) { @@ -312,5 +408,24 @@ extension EventReporter: TypeIdentifying { } func setLastEventResponseDate(_ date: Date) { lastEventResponseDate = date } + + /// Occupies the queue a delivery runs on, so a test can see what one that has not started yet collects. + func occupyQueue(until released: DispatchSemaphore) { + eventQueue.async { _ = released.wait(timeout: .now() + 10) } + } + + /// Callers waiting on the queued delivery. + var queuedDeliveryWaiterCount: Int { + deliveryLock.lock() + defer { deliveryLock.unlock() } + return queuedDelivery?.count ?? 0 + } + + /// Deliveries started, counting one for a run of flushes that joined each other. + var startedDeliveryCount: Int { + deliveryLock.lock() + defer { deliveryLock.unlock() } + return deliveryRunCount + } } #endif diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 898eb8d45..c2c3058a6 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -1368,6 +1368,19 @@ final class LDClientSpec: QuickSpec { expect(testContext.eventReporterMock.isOnline) == true expect(testContext.flagSynchronizerMock.isOnline) == false } + it("tries to deliver what it has before the process is suspended, where it is suspended") { + let testContext = TestContext(startOnline: true, enableBackgroundUpdates: false) + testContext.start() + NotificationCenter.default.post(name: SystemCapabilities.backgroundNotification!, object: self) + + #if os(iOS) || os(tvOS) + expect(testContext.eventReporterMock.flushReportingOutcomeCallCount).toEventually(equal(1)) + #else + // macOS posts this on every loss of focus, which suspends nothing. + expect(testContext.subject.runMode).toEventually(equal(LDClientRunMode.background)) + expect(testContext.eventReporterMock.flushReportingOutcomeCallCount) == 0 + #endif + } it("background updates enabled") { let testContext = TestContext(startOnline: true) testContext.start() @@ -1638,6 +1651,165 @@ final class LDClientSpec: QuickSpec { expect(testContext.eventReporterMock.flushCallCount) == 1 } } + + describe("flush with a completion") { + /// Answers from another thread, because this call does not block and must not be answered inside it. + func answer(_ mock: EventReportingMock, with delivered: Bool, after delay: TimeInterval = 0.05) { + mock.flushReportingOutcomeCallback = { [weak mock] in + let completion = mock?.flushReportingOutcomeReceivedCompletion + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { completion?(delivered) } + } + } + + it("reports true when delivery succeeds") { + let testContext = TestContext() + testContext.start() + answer(testContext.eventReporterMock, with: true) + + var delivered: Bool? + testContext.subject.flush { delivered = $0 } + // Nothing has answered yet, so a call that reported now would be blocking or guessing. + expect(delivered).to(beNil()) + expect(delivered).toEventually(beTrue()) + } + + it("reports false when delivery cannot finish") { + let testContext = TestContext() + testContext.start() + answer(testContext.eventReporterMock, with: false) + + var delivered: Bool? + testContext.subject.flush { delivered = $0 } + expect(delivered).toEventually(beFalse()) + } + + it("reports false for a client a later start replaced, without flushing the clients that replaced it") { + let stale = TestContext() + stale.start() + stale.subject.close() + let current = TestContext() + current.start() + answer(current.eventReporterMock, with: true) + + var delivered: Bool? + stale.subject.flush { delivered = $0 } + expect(delivered) == false + expect(current.eventReporterMock.flushReportingOutcomeCallCount) == 0 + } + + it("reports once every environment has answered, and only once") { + let testContext = TestContext() + try testContext.config.setSecondaryMobileKeys(["alternate": Constants.alternateMockMobileKey]) + testContext.start() + let answering = LDClient.get(environment: "alternate")?.eventReporter as? EventReportingMock + expect(answering).toNot(beNil()) + // The one that fails answers last, so reporting before every environment is in would say true. + answer(testContext.eventReporterMock, with: true, after: 0.05) + answer(answering!, with: false, after: 0.2) + + var answers: [Bool] = [] + testContext.subject.flush { answers.append($0) } + expect(answers).toEventually(equal([false])) + Thread.sleep(forTimeInterval: 0.2) + expect(answers) == [false] + } + } + + describe("flushAndWait") { + /// Answers inside the call, because `flushAndWait` blocks the test thread. + func answer(_ mock: EventReportingMock, with delivered: Bool) { + mock.flushReportingOutcomeCallback = { [weak mock] in + mock?.flushReportingOutcomeReceivedCompletion?(delivered) + } + } + + it("reports true when delivery succeeds") { + let testContext = TestContext() + testContext.start() + answer(testContext.eventReporterMock, with: true) + + expect(testContext.subject.flushAndWait(timeout: 1.0)) == true + expect(testContext.eventReporterMock.flushReportingOutcomeCallCount) == 1 + } + + it("reports false when delivery cannot finish") { + let testContext = TestContext() + testContext.start() + answer(testContext.eventReporterMock, with: false) + + expect(testContext.subject.flushAndWait(timeout: 1.0)) == false + } + + it("reports false when the budget expires first") { + let testContext = TestContext() + testContext.start() + testContext.eventReporterMock.flushReportingOutcomeCallback = nil + + expect(testContext.subject.flushAndWait(timeout: 0.05)) == false + } + + it("reports false once closed") { + let testContext = TestContext() + testContext.start() + answer(testContext.eventReporterMock, with: true) + testContext.subject.close() + + expect(testContext.subject.flushAndWait(timeout: 1.0)) == false + } + + it("reports false for a client a later start replaced, without flushing the clients that replaced it") { + let stale = TestContext() + stale.start() + stale.subject.close() + let current = TestContext() + current.start() + answer(current.eventReporterMock, with: true) + + // Its own events were never attempted, so the outcome of another client's delivery says nothing of them. + expect(stale.subject.flushAndWait(timeout: 1.0)) == false + expect(current.eventReporterMock.flushReportingOutcomeCallCount) == 0 + } + + it("gives every environment the whole budget rather than what the one before it left") { + let testContext = TestContext() + try testContext.config.setSecondaryMobileKeys(["alternate": Constants.alternateMockMobileKey]) + testContext.start() + + // Each takes most of the budget, so delivering them one after another would leave the second + // environment past the deadline before its own delivery had begun. + for environment in ["alternate", LDConfig.Constants.primaryEnvironmentName] { + let reporter = LDClient.get(environment: environment)?.eventReporter as? EventReportingMock + expect(reporter).toNot(beNil()) + reporter?.flushReportingOutcomeCallback = { [weak reporter] in + let completion = reporter?.flushReportingOutcomeReceivedCompletion + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { completion?(true) } + } + } + + expect(testContext.subject.flushAndWait(timeout: 0.5)) == true + } + + it("takes no longer for several environments than the budget it was given") { + let testContext = TestContext() + try testContext.config.setSecondaryMobileKeys(["alternate": Constants.alternateMockMobileKey]) + testContext.start() + // One answers most of the way through the budget and the other never does, so the budget the first + // spent is the budget the second has left. Giving each its own copy of it would take twice as long. + let answering = LDClient.get(environment: "alternate")?.eventReporter as? EventReportingMock + expect(answering).toNot(beNil()) + answering?.flushReportingOutcomeCallback = { [weak answering] in + let completion = answering?.flushReportingOutcomeReceivedCompletion + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { completion?(true) } + } + testContext.eventReporterMock.flushReportingOutcomeCallback = nil + + // Answering at 0.5s into a 0.6s budget leaves the second wait 0.1s, against the 0.6s a fresh copy + // would give it, so the two outcomes are 0.6s and 1.1s with room on either side of the threshold. + let started = Date() + expect(testContext.subject.flushAndWait(timeout: 0.6)) == false + expect(Date().timeIntervalSince(started)) < 0.85 + } + } } private func allFlagsSpec() { diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift index adf261c7d..1a0fe0c0e 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift @@ -82,7 +82,12 @@ final class ClientServiceMockFactory: ClientServiceCreating { makeEventReporterReceivedService = service onEventSyncComplete = onSyncComplete - return EventReportingMock() + let mock = EventReportingMock() + // Answer by default so backgrounding does not hold its activity assertion until timeout. + mock.flushReportingOutcomeCallback = { [weak mock] in + mock?.flushReportingOutcomeReceivedCompletion?(true) + } + return mock } func makeEventReporter(config: LDConfig, service: DarklyServiceProvider) -> EventReporting { diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift index e1e36ee4e..94dca6865 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift @@ -133,13 +133,66 @@ final class DarklyServiceMock: DarklyServiceProvider { return mock } - var stubbedEventResponse: ServiceResponse? - var publishEventDataCallCount = 0 - var publishedEventData: Data? + /// Guards the event-publish state below. Publishes arrive on the reporter's threads, and the retry's, while the + /// test thread sets the stubs and reads the results. + private let eventLock = NSLock() + private var _stubbedEventResponse: ServiceResponse? + private var _publishEventDataCallCount = 0 + private var _publishedEventData: Data? + private var _holdsEventCompletions = false + private var heldEventCompletions: [ServiceCompletionHandler] = [] + + var stubbedEventResponse: ServiceResponse? { + get { withEventLock { _stubbedEventResponse } } + set { withEventLock { _stubbedEventResponse = newValue } } + } + var publishEventDataCallCount: Int { + get { withEventLock { _publishEventDataCallCount } } + set { withEventLock { _publishEventDataCallCount = newValue } } + } + var publishedEventData: Data? { + get { withEventLock { _publishedEventData } } + set { withEventLock { _publishedEventData = newValue } } + } + /// Holds responses so a test can act while a delivery is in flight. + var holdsEventCompletions: Bool { + get { withEventLock { _holdsEventCompletions } } + set { withEventLock { _holdsEventCompletions = newValue } } + } + func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?) { - publishEventDataCallCount += 1 - publishedEventData = eventData - completion?(stubbedEventResponse ?? (nil, nil, nil, nil)) + // Decided under the lock, answered outside it: the completion re-enters the reporter, which may publish again. + let response: ServiceResponse? = withEventLock { + _publishEventDataCallCount += 1 + _publishedEventData = eventData + guard !_holdsEventCompletions + else { + if let completion { + heldEventCompletions.append(completion) + } + return nil + } + return _stubbedEventResponse ?? (nil, nil, nil, nil) + } + if let response { + completion?(response) + } + } + + /// Answers every held request. + func releaseHeldEventCompletions() { + let (held, response) = withEventLock { () -> ([ServiceCompletionHandler], ServiceResponse) in + let held = heldEventCompletions + heldEventCompletions = [] + return (held, _stubbedEventResponse ?? (nil, nil, nil, nil)) + } + held.forEach { $0(response) } + } + + private func withEventLock(_ body: () -> T) -> T { + eventLock.lock() + defer { eventLock.unlock() } + return body() } var stubbedDiagnosticResponse: ServiceResponse? diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index 19a917e48..d7d6d227b 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -629,10 +629,12 @@ final class DarklyServiceSpec: QuickSpec { eventsPublished = true } } - it("does not make a request") { + it("does not make a request, and still answers with a failure") { expect(eventRequest).to(beNil()) - expect(eventsPublished) == false - expect(responses).to(beNil()) + // A reporter holds every later delivery until this one answers, so it must not be left waiting. + expect(eventsPublished) == true + expect(responses.urlResponse).to(beNil()) + expect(responses.error).toNot(beNil()) } } } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/BackgroundActivitySpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/BackgroundActivitySpec.swift new file mode 100644 index 000000000..f3de67f69 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/BackgroundActivitySpec.swift @@ -0,0 +1,164 @@ +import Foundation +import Quick +import Nimble +@testable import LaunchDarkly + +final class BackgroundActivitySpec: QuickSpec { + override func spec() { + describe("a background activity") { + var assertions = 0 + var released = 0 + var activity: BackgroundActivity! + beforeEach { + assertions = 0 + released = 0 + activity = BackgroundActivity(reason: "test") { _, work in + assertions += 1 + work { released += 1 } + } + } + + it("holds the assertion until the work finishes") { + var finish: (() -> Void)? + activity.run { finish = $0 } + + expect(assertions) == 1 + expect(released) == 0 + + finish?() + expect(released) == 1 + } + + it("does not start a second assertion while one is held, and runs the later work under the first") { + var finishFirst: (() -> Void)? + var finishSecond: (() -> Void)? + var secondRuns = 0 + activity.run { finishFirst = $0 } + activity.run { finished in + secondRuns += 1 + finishSecond = finished + } + + expect(assertions) == 1 + expect(secondRuns) == 0 + + finishFirst?() + expect(secondRuns) == 1 + expect(released) == 0 + + finishSecond?() + expect(released) == 1 + expect(assertions) == 1 + } + + it("runs the work once more however many requests arrived while it was held") { + var finishFirst: (() -> Void)? + var laterRuns = 0 + activity.run { finishFirst = $0 } + for _ in 0..<3 { + activity.run { finished in + laterRuns += 1 + finished() + } + } + + finishFirst?() + expect(laterRuns) == 1 + expect(assertions) == 1 + expect(released) == 1 + } + + it("starts a new assertion once the held one is released") { + activity.run { $0() } + activity.run { $0() } + + expect(assertions) == 2 + expect(released) == 2 + } + } + + #if os(iOS) || os(tvOS) || os(watchOS) + describe("an assertion from the process") { + /// Stands in for `performExpiringActivity`, which answers on a system queue rather than the caller's. + /// + /// Signals `ended` once the block it was given returns, which is when the real system would take the + /// assertion back. + func system(granting: Bool, ended: DispatchSemaphore) -> BackgroundActivity.ExpiringActivity { + { _, whileGranted in + DispatchQueue.global().async { + whileGranted(!granting) + ended.signal() + } + } + } + + it("runs the work, and holds the assertion no longer, when the system grants one") { + let ended = DispatchSemaphore(value: 0) + let assertion = BackgroundActivity.processAssertion(logger: .disabled, + perform: system(granting: true, ended: ended), + maximumDuration: 30) + + var ran = false + assertion("test") { finished in + ran = true + finished() + } + + expect(ran) == true + // Held for as long as the work took rather than the full 30s, which this would otherwise wait out. + expect(ended.wait(timeout: .now() + 2)) == .success + } + + it("runs the work even where the system refuses an assertion outright") { + // Refusing calls the block once with `expired`, and never with `granted`. Work that ran inside that + // block would never run at all, and under resource pressure that is exactly when it is needed. + let ended = DispatchSemaphore(value: 0) + let assertion = BackgroundActivity.processAssertion(logger: .disabled, + perform: system(granting: false, ended: ended), + maximumDuration: 30) + + var ran = false + assertion("test") { finished in + ran = true + finished() + } + + expect(ran) == true + expect(ended.wait(timeout: .now() + 2)) == .success + } + + it("gives up the assertion at the maximum, where the work never finishes") { + let ended = DispatchSemaphore(value: 0) + let assertion = BackgroundActivity.processAssertion(logger: .disabled, + perform: system(granting: true, ended: ended), + maximumDuration: 0.3) + + let started = Date() + assertion("test") { _ in } + + expect(ended.wait(timeout: .now() + 3)) == .success + // Ended by the cap, rather than by the work or by never having been held. + expect(Date().timeIntervalSince(started)) > 0.2 + } + } + #else + describe("an assertion from the process") { + it("runs the work on the calling thread without asking the system, which has no assertions here") { + var asked = false + let assertion = BackgroundActivity.processAssertion(logger: .disabled, + perform: { _, _ in asked = true }, + maximumDuration: 30) + + var ran = false + assertion("test") { finished in + ran = true + finished() + } + + expect(ran) == true + expect(asked) == false + } + } + #endif + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift index 229e7e72a..4062a7ba5 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift @@ -63,6 +63,27 @@ final class EventReporterSpec: QuickSpec { eventReporter.record(event) } } + + /// Flushes, and answers whether recording from the completion got anywhere. + /// + /// Recording waits on the reporter's queue, so a completion run on that queue cannot record. The recording + /// happens on another thread against a deadline, so that a reporter answering on its own queue fails this + /// rather than hanging the test process on a deadlock it can never leave. + func recordedFromACompletion() -> Bool { + var recorded = false + waitUntil(timeout: .seconds(5)) { done in + eventReporter.flushReportingOutcome { _ in + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + eventReporter.record(CustomEvent(key: "recorded from a completion", context: LDContext.stub())) + finished.signal() + } + recorded = finished.wait(timeout: .now() + 1) == .success + done() + } + } + return recorded + } } override func spec() { @@ -71,10 +92,192 @@ final class EventReporterSpec: QuickSpec { recordEventSpec() testRecordFlagEvaluationEvents() reportEventsSpec() + flushReportingOutcomeSpec() unserializableEventSpec() reportTimerSpec() } + private func flushReportingOutcomeSpec() { + describe("flushReportingOutcome") { + var testContext: TestContext! + afterEach { + testContext.eventReporter.isOnline = false + } + + it("reports true when LaunchDarkly accepts the events") { + testContext = TestContext() + testContext.recordEvents(1) + testContext.eventReporter.isOnline = true + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == true + } + + it("reports true when there is nothing to deliver") { + testContext = TestContext() + testContext.eventReporter.isOnline = true + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == true + } + + it("reports false while offline") { + testContext = TestContext() + testContext.recordEvents(1) + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == false + expect(testContext.serviceMock.publishEventDataCallCount) == 0 + } + + it("keeps draining the event store while a delivery is in flight") { + testContext = TestContext() + testContext.recordEvents(1) + testContext.eventReporter.isOnline = true + testContext.serviceMock.holdsEventCompletions = true + + testContext.eventReporter.flushReportingOutcome { _ in } + expect(testContext.serviceMock.publishEventDataCallCount).toEventually(equal(1)) + + // Holding this back until the first delivery answers would let the store fill through a retry, which + // can run for twice the connection timeout, and events recorded past the capacity are lost. + testContext.recordEvents(1) + testContext.eventReporter.flushReportingOutcome { _ in } + expect(testContext.serviceMock.publishEventDataCallCount).toEventually(equal(2)) + + testContext.serviceMock.holdsEventCompletions = false + testContext.serviceMock.releaseHeldEventCompletions() + } + + it("reports false when LaunchDarkly permanently refuses the events, and goes offline") { + testContext = TestContext() + let unauthorized = HTTPURLResponse(url: testContext.serviceMock.config.eventsUrl, + statusCode: HTTPURLResponse.StatusCodes.unauthorized, + httpVersion: DarklyServiceMock.Constants.httpVersion, + headerFields: nil) + testContext.serviceMock.stubbedEventResponse = (nil, unauthorized, nil, nil) + testContext.recordEvents(1) + testContext.eventReporter.isOnline = true + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == false + expect(testContext.eventReporter.isOnline) == false + } + + it("reports false when no event could be serialized") { + testContext = TestContext() + testContext.eventReporter.record(CustomEvent(key: "poison", context: LDContext.stub(), metricValue: .nan)) + testContext.eventReporter.isOnline = true + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == false + expect(testContext.serviceMock.publishEventDataCallCount) == 0 + } + + it("reports false when some events could not be serialized, though the rest were accepted") { + testContext = TestContext() + testContext.eventReporter.record(CustomEvent(key: "poison", context: LDContext.stub(), metricValue: .nan)) + testContext.eventReporter.record(CustomEvent(key: "kept", context: LDContext.stub(), metricValue: 1.0)) + testContext.eventReporter.isOnline = true + + var delivered: Bool? + waitUntil { done in + testContext.eventReporter.flushReportingOutcome { result in + delivered = result + done() + } + } + expect(delivered) == false + expect(testContext.serviceMock.publishEventDataCallCount) == 1 + } + + it("answers away from the queue a recorded event waits on, after a delivery") { + testContext = TestContext() + testContext.recordEvents(1) + testContext.eventReporter.isOnline = true + + expect(testContext.recordedFromACompletion()) == true + } + + it("answers away from the queue a recorded event waits on, with nothing to send") { + testContext = TestContext() + testContext.eventReporter.isOnline = true + + // The path that answers without ever leaving the reporter's queue, so it is the one that would + // deadlock while the delivery path, answering from the response, did not. + expect(testContext.recordedFromACompletion()) == true + } + + it("sends one delivery for the flushes that arrive before it starts, and answers them all") { + testContext = TestContext(stubResponseSuccess: false) + testContext.recordEvents(1) + testContext.eventReporter.isOnline = true + let released = DispatchSemaphore(value: 0) + testContext.eventReporter.occupyQueue(until: released) + + // An application flushing in a loop would otherwise queue a delivery each time, leaving the flush + // that matters waiting behind all of them, and growing the waiter list without bound. + for _ in 0..<100 { + testContext.eventReporter.flush(completion: nil) + } + expect(testContext.eventReporter.queuedDeliveryWaiterCount) == 0 + + var delivered: [Bool] = [] + let answers = UnfairLock() + // Both completions run at once, and the poll below reads from a third thread. + let answered: () -> [Bool] = { + answers.lock() + defer { answers.unlock() } + return delivered + } + for _ in 0..<2 { + testContext.eventReporter.flushReportingOutcome { result in + answers.lock() + delivered.append(result) + answers.unlock() + } + } + expect(testContext.eventReporter.queuedDeliveryWaiterCount) == 2 + + released.signal() + // Allows for the one-second retry; the failed events are dropped. + expect(answered()).toEventually(equal([false, false]), timeout: .seconds(10)) + expect(testContext.eventReporter.startedDeliveryCount) == 1 + expect(testContext.serviceMock.publishEventDataCallCount) == 2 // the first attempt and its retry + } + } + } + private func initSpec() { describe("init") { var testContext: TestContext! diff --git a/SourceryTemplates/mocks.stencil b/SourceryTemplates/mocks.stencil index b03faefb8..9fa61fd20 100644 --- a/SourceryTemplates/mocks.stencil +++ b/SourceryTemplates/mocks.stencil @@ -25,7 +25,7 @@ final class {{ type.name }}Mock: {{ type.name }} { var {{ method.callName }}CallCount = 0 var {{ method.callName }}Callback: (() throws -> Void)? {% if method.throws %} var {{ method.callName }}ShouldThrow: Error?{% endif %} -{% if method.parameters.count == 1 %} var {{ method.callName }}Received{% for param in method.parameters %}{{ param.name|upperFirstLetter }}: {{ param.typeName.unwrappedTypeName }}?{% endfor %} +{% if method.parameters.count == 1 %} var {{ method.callName }}Received{% for param in method.parameters %}{{ param.name|upperFirstLetter }}: {% if param.typeAttributes.escaping %}({{ param.unwrappedTypeName }})?{% else %}{{ param.typeName.unwrappedTypeName }}?{% endif %}{% endfor %} {% else %}{% if not method.parameters.count == 0 %} var {{ method.callName }}ReceivedArguments: ({% for param in method.parameters %}{{ param.name }}: {% if param.typeAttributes.escaping %}{{ param.unwrappedTypeName }}{% else %}{{ param.typeName }}{% endif %}{% if not forloop.last %}, {% endif %}{% endfor %})?{% endif %} {% endif %} {% if not method.returnTypeName.isVoid %} var {{ method.callName }}ReturnValue: {{ method.returnTypeName }}{% if method.annotations.DefaultReturnValue %} = {{ method.annotations.DefaultReturnValue }}{% else %}{% if not method.isOptionalReturnType %}!{% endif %}{% endif %}{% endif %}