diff --git a/FirebaseSessions.podspec b/FirebaseSessions.podspec index 222192db88d..7d9d1b0ca51 100644 --- a/FirebaseSessions.podspec +++ b/FirebaseSessions.podspec @@ -46,7 +46,6 @@ Pod::Spec.new do |s| s.dependency 'GoogleUtilities/Environment', '>= 8.1.3', '< 9.0' s.dependency 'GoogleUtilities/UserDefaults', '>= 8.1.3', '< 9.0' s.dependency 'nanopb', '~> 3.30910.0' - s.dependency 'PromisesSwift', '>= 2.4.1', '< 3.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"', diff --git a/FirebaseSessions/Sources/FirebaseSessions.swift b/FirebaseSessions/Sources/FirebaseSessions.swift index 9e79ae57b5d..16f93f65127 100644 --- a/FirebaseSessions/Sources/FirebaseSessions.swift +++ b/FirebaseSessions/Sources/FirebaseSessions.swift @@ -20,20 +20,12 @@ internal import FirebaseCoreExtension internal import FirebaseInstallations internal import GoogleDataTransport -#if swift(>=6.0) - internal import Promises -#elseif swift(>=5.10) - import Promises -#else - internal import Promises -#endif - private enum GoogleDataTransportConfig { static let sessionsLogSource = "1974" static let sessionsTarget = GDTCORTarget.FLL } -@objc(FIRSessions) final class Sessions: NSObject, Library, SessionsProvider { +@objc(FIRSessions) final class Sessions: NSObject, Library, SessionsProvider, @unchecked Sendable { // MARK: - Private Variables /// The Firebase App ID associated with Sessions. @@ -46,13 +38,12 @@ private enum GoogleDataTransportConfig { private let appInfo: ApplicationInfoProtocol private let settings: SettingsProtocol - /// Subscribers - /// `subscribers` are used to determine the Data Collection state of the Sessions SDK. - /// If any Subscribers has Data Collection enabled, the Sessions SDK will send events - private var subscribers: [SessionsSubscriber] = [] - /// `subscriberPromises` are used to wait until all Subscribers have registered - /// themselves. Subscribers must have Data Collection state available upon registering. - private var subscriberPromises: [SessionsSubscriberName: Promise] = [:] + /// `state` holds the mutable state (subscribers array and registration) + /// ensuring mathematical safety in Swift Concurrency. + private let state: SessionsState + + /// Queue for callbacks + private let loggedEventCallbackQueue: DispatchQueue /// Notifications static let SessionIDChangedNotificationName = Notification @@ -91,7 +82,8 @@ private enum GoogleDataTransportConfig { coordinator: coordinator, initiator: initiator, appInfo: appInfo, - settings: settings) { result in + settings: settings, + loggedEventCallbackQueue: .global(qos: .background)) { result in switch result { case .success(()): Logger.logInfo("Successfully logged Session Start event") @@ -150,13 +142,12 @@ private enum GoogleDataTransportConfig { self.initiator = initiator self.appInfo = appInfo self.settings = settings - - super.init() + self.loggedEventCallbackQueue = loggedEventCallbackQueue let dependencies = SessionsDependencies.dependencies - for subscriberName in dependencies { - subscriberPromises[subscriberName] = Promise.pending() - } + state = SessionsState(expectedSubscribers: dependencies) + + super.init() Logger .logDebug( @@ -176,42 +167,55 @@ private enum GoogleDataTransportConfig { // If there are no Dependencies, then the Sessions SDK can't acknowledge // any products data collection state, so the Sessions SDK won't send events. - guard !self.subscriberPromises.isEmpty else { - loggedEventCallback(.failure(.NoDependenciesError)) + guard !self.state.expectedSubscribers.isEmpty else { + self.loggedEventCallbackQueue.async { + loggedEventCallback(.failure(.NoDependenciesError)) + } return } - // Wait until all subscriber promises have been fulfilled before + // Wait until all expected subscribers have registered before // doing any data collection. - all(self.subscriberPromises.values).then(on: loggedEventCallbackQueue) { _ in - guard self.isAnyDataCollectionEnabled else { - loggedEventCallback(.failure(.DataCollectionError)) - return - } - - Logger.logDebug("Data Collection is enabled for at least one Subscriber") - - // Fetch settings if they have expired. This must happen after the check for - // data collection because it uses the network, but it must happen before the - // check for sessionsEnabled from Settings because otherwise we would permanently - // turn off the Sessions SDK when we disabled it. - self.settings.updateSettings() - - self.addSubscriberFields(event: event) - event.setSamplingRate(samplingRate: self.settings.samplingRate) - - guard sessionInfo.shouldDispatchEvents else { - loggedEventCallback(.failure(.SessionSamplingError)) - return - } - - guard self.settings.sessionsEnabled else { - loggedEventCallback(.failure(.DisabledViaSettingsError)) - return - } - - self.coordinator.attemptLoggingSessionStart(event: event) { result in - loggedEventCallback(result) + Task { + let subscribers = await self.state.waitUntilAllRegistered() + + self.loggedEventCallbackQueue.async { + let isAnyDataCollectionEnabled = subscribers.contains { $0.isDataCollectionEnabled } + guard isAnyDataCollectionEnabled else { + loggedEventCallback(.failure(.DataCollectionError)) + return + } + + Logger.logDebug("Data Collection is enabled for at least one Subscriber") + + // Fetch settings if they have expired. This must happen after the check for + // data collection because it uses the network, but it must happen before the + // check for sessionsEnabled from Settings because otherwise we would permanently + // turn off the Sessions SDK when we disabled it. + self.settings.updateSettings() + let samplingRate = self.settings.samplingRate + let sessionsEnabled = self.settings.sessionsEnabled + + for subscriber in subscribers { + event.set(subscriber: subscriber.sessionsSubscriberName, + isDataCollectionEnabled: subscriber.isDataCollectionEnabled, + appInfo: self.appInfo) + } + event.setSamplingRate(samplingRate: samplingRate) + + guard sessionInfo.shouldDispatchEvents else { + loggedEventCallback(.failure(.SessionSamplingError)) + return + } + + guard sessionsEnabled else { + loggedEventCallback(.failure(.DisabledViaSettingsError)) + return + } + + self.coordinator.attemptLoggingSessionStart(event: event) { result in + loggedEventCallback(result) + } } } } @@ -228,23 +232,6 @@ private enum GoogleDataTransportConfig { // MARK: - Data Collection - var isAnyDataCollectionEnabled: Bool { - for subscriber in subscribers { - if subscriber.isDataCollectionEnabled { - return true - } - } - return false - } - - func addSubscriberFields(event: SessionStartEvent) { - for subscriber in subscribers { - event.set(subscriber: subscriber.sessionsSubscriberName, - isDataCollectionEnabled: subscriber.isDataCollectionEnabled, - appInfo: appInfo) - } - } - // MARK: - SessionsProvider var currentSessionDetails: SessionDetails { @@ -266,7 +253,7 @@ private enum GoogleDataTransportConfig { } } - func register(subscriber: SessionsSubscriber) { + @objc(registerWithSubscriber:) func register(subscriber: SessionsSubscriber) { Logger .logDebug( "Registering Sessions SDK subscriber with name: \(subscriber.sessionsSubscriberName), data collection enabled: \(subscriber.isDataCollectionEnabled)" @@ -290,9 +277,17 @@ private enum GoogleDataTransportConfig { // before subscribers, so subscribers will miss the first Notification subscriber.onSessionChanged(currentSessionDetails) - // Fulfil this subscriber's promise - subscribers.append(subscriber) - subscriberPromises[subscriber.sessionsSubscriberName]?.fulfill(()) + // Register this subscriber to resume any waiting tasks. + // + // Unlike the previous promise-based implementation, this hop is + // asynchronous: returning from `register(subscriber:)` does not guarantee + // the subscriber is visible to an in-flight session start. That is safe + // because a session start awaits `waitUntilAllRegistered()`, which only + // proceeds once this `Task` has run for every expected subscriber. + let subscriberName = subscriber.sessionsSubscriberName + Task { + await state.register(subscriber: subscriber, name: subscriberName) + } } // MARK: - Library conformance diff --git a/FirebaseSessions/Sources/Public/SessionsProvider.swift b/FirebaseSessions/Sources/Public/SessionsProvider.swift index ef73e182b31..5ca2113e469 100644 --- a/FirebaseSessions/Sources/Public/SessionsProvider.swift +++ b/FirebaseSessions/Sources/Public/SessionsProvider.swift @@ -19,5 +19,5 @@ import Foundation // interface for other 1P SDKs to talk to. @objc(FIRSessionsProvider) public protocol SessionsProvider { - @objc func register(subscriber: SessionsSubscriber) + @objc(registerWithSubscriber:) func register(subscriber: SessionsSubscriber) } diff --git a/FirebaseSessions/Sources/SessionStartEvent.swift b/FirebaseSessions/Sources/SessionStartEvent.swift index 8b3929054fc..1687e247ca9 100644 --- a/FirebaseSessions/Sources/SessionStartEvent.swift +++ b/FirebaseSessions/Sources/SessionStartEvent.swift @@ -32,7 +32,17 @@ internal import GoogleDataTransport /// 1) Writing fields to the Session proto /// 2) Synthesizing itself for persisting to disk and logging to GoogleDataTransport /// -class SessionStartEvent: NSObject, GDTCOREventDataObject { +/// - Note: This type is `@unchecked Sendable` rather than genuinely thread safe. +/// It wraps a mutable nanopb struct holding manually managed pointers that are +/// freed in `deinit`, so concurrent mutation would corrupt memory. The safety +/// invariant is that an event instance is only ever *handed off* between +/// executors, never shared: it is created on the initiator's thread, mutated +/// on a single callback queue, and then handed to the coordinator, which +/// serializes its own writes. Do not retain an event across those stages or +/// mutate it from more than one context. +/// TODO: Make this checked `Sendable` by making the proto writes internally +/// synchronized, or by modeling the event as a value type. +class SessionStartEvent: NSObject, GDTCOREventDataObject, @unchecked Sendable { var proto: firebase_appquality_sessions_SessionEvent init(sessionInfo: SessionInfo, appInfo: ApplicationInfoProtocol, diff --git a/FirebaseSessions/Sources/SessionsState.swift b/FirebaseSessions/Sources/SessionsState.swift new file mode 100644 index 00000000000..49ac1ee97a2 --- /dev/null +++ b/FirebaseSessions/Sources/SessionsState.swift @@ -0,0 +1,89 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// An internal actor that protects mutable state for the Sessions SDK. +/// +/// This replaces the previous `FBLPromise`-based registration barrier. It acts +/// as a *latching gate*: once every expected subscriber has registered, the +/// gate stays open for the remaining lifetime of the process, so session +/// starts after the first one resolve without suspending. That mirrors the old +/// behavior, where the per-subscriber promises stayed fulfilled once resolved. +/// +/// If an expected subscriber never registers, waiters are never resumed and no +/// event is sent. This is also the pre-existing behavior of the unfulfilled +/// promises. +actor SessionsState { + private var subscribers: [SessionsSubscriber] = [] + private var registeredSubscribers: Set = [] + nonisolated let expectedSubscribers: Set + private var continuations: [CheckedContinuation] = [] + + init(expectedSubscribers: Set) { + self.expectedSubscribers = expectedSubscribers + } + + /// Records a subscriber and opens the gate once all expected subscribers + /// have registered. + /// + /// Registration is idempotent per subscriber *name*. Subscribers that were + /// never declared as dependencies are still tracked (so they contribute to + /// the data collection check) but cannot open the gate on their own. + /// + /// This method is deliberately non-`async`: it contains no suspension + /// points, so the actor runs it to completion. The de-duplication check, the + /// append, and the continuation resume therefore cannot interleave with + /// another `register` or `waitUntilAllRegistered` call. + func register(subscriber: SessionsSubscriber, name: SessionsSubscriberName) { + guard !registeredSubscribers.contains(name) else { return } + subscribers.append(subscriber) + registeredSubscribers.insert(name) + if registeredSubscribers.isSuperset(of: expectedSubscribers) { + for continuation in continuations { + continuation.resume() + } + continuations.removeAll() + } + } + + /// Suspends until every expected subscriber has registered, then returns a + /// snapshot of the registered subscribers. + /// + /// The snapshot is returned from here rather than read through a separate + /// accessor so that the session-start path only needs a single actor hop. + /// As before, the snapshot reflects the state *after* the gate opens, so a + /// subscriber that registers late is still included. + func waitUntilAllRegistered() async -> [SessionsSubscriber] { + if expectedSubscribers.isEmpty || registeredSubscribers.isSuperset(of: expectedSubscribers) { + return subscribers + } + // No lost-wakeup race here: the closure passed to `withCheckedContinuation` + // runs synchronously in this actor's isolation domain before the caller + // suspends. A `register` call therefore cannot slip in between the check + // above and the append below, so the continuation is always either + // enqueued before the gate opens, or the fast path above already returned. + // + // Note: If cancellation is required, use withTaskCancellationHandler and a throwing + // continuation. + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + return subscribers + } + + var currentSubscribers: [SessionsSubscriber] { + subscribers + } +} diff --git a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift index 462be23ead3..31a64472046 100644 --- a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift +++ b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift @@ -24,8 +24,8 @@ import XCTest final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase { // MARK: - Test Settings & Sampling - @MainActor func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() { - runSessionsSDK( + @MainActor func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() async { + await runSessionsSDK( subscriberSDKs: [ mockPerformanceSubscriber, @@ -49,8 +49,8 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase { ) } - @MainActor func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() { - runSessionsSDK( + @MainActor func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() async { + await runSessionsSDK( subscriberSDKs: [ mockPerformanceSubscriber, @@ -84,15 +84,15 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase { // This test ensures that if we go into the background for longer than // the Session Timeout, we log another event when we come to the foreground. // - // We wanted to make sure that since we've introduced promises, - // once the promise has been fulfilled, that .then'ing on the promise + // We wanted to make sure that since we've introduced Swift Concurrency, + // once all expected subscribers have been registered, awaiting on the registration // in future initiations still results in a log - @MainActor func test_multipleInitiations_logsSessionEventEachInitiation() { + @MainActor func test_multipleInitiations_logsSessionEventEachInitiation() async { var loggedCount = 0 var lastLoggedSessionID = "" let loggedTwiceExpectation = expectation(description: "Sessions SDK logged events twice") - runSessionsSDK( + await runSessionsSDK( subscriberSDKs: [ mockPerformanceSubscriber, @@ -138,7 +138,7 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase { } ) - wait(for: [loggedTwiceExpectation], timeout: 3) + await fulfillment(of: [loggedTwiceExpectation], timeout: 3) // Make sure we logged 2 events XCTAssertEqual(loggedCount, 2) diff --git a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift index 5a2c5d1da97..ae9db9d64a6 100644 --- a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift +++ b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift @@ -81,19 +81,19 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase // MARK: - Test Data Collection - @MainActor func test_subscriberWithDataCollectionEnabled_logsSessionEvent() { - runSessionsSDK( + @MainActor func test_subscriberWithDataCollectionEnabled_logsSessionEvent() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, ], preSessionsInit: { _ in // Nothing }, postSessionsInit: { - sessions.register(subscriber: self.mockCrashlyticsSubscriber) - // Sessions hasn't logged yet because no Subscriber SDKs have registered XCTAssertNil(self.mockCoordinator.loggedEvent) + sessions.register(subscriber: self.mockCrashlyticsSubscriber) + }, postLogEvent: { result, subscriberSDKs in // Make sure the SDK reported success, we logged an event and // Settings fetched new configs @@ -105,8 +105,8 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase ) } - @MainActor func test_subscribersSomeDataCollectionDisabled_logsSessionEvent() { - runSessionsSDK( + @MainActor func test_subscribersSomeDataCollectionDisabled_logsSessionEvent() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, mockPerformanceSubscriber, @@ -132,8 +132,8 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase ) } - @MainActor func test_subscribersAllDataCollectionDisabled_doesNotLogSessionEvent() { - runSessionsSDK( + @MainActor func test_subscribersAllDataCollectionDisabled_doesNotLogSessionEvent() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, mockPerformanceSubscriber, @@ -159,8 +159,8 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase ) } - @MainActor func test_defaultSamplingRate_isSetInProto() { - runSessionsSDK( + @MainActor func test_defaultSamplingRate_isSetInProto() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, diff --git a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift index 139a8826e0e..c19a042ac7c 100644 --- a/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift +++ b/FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift @@ -37,8 +37,8 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase { // MARK: - Test Subscriber Callbacks - @MainActor func test_registerSubscriber_callsOnSessionChanged() { - runSessionsSDK( + @MainActor func test_registerSubscriber_callsOnSessionChanged() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, mockPerformanceSubscriber, @@ -61,8 +61,8 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase { // Make sure that even if the Sessions SDK is disabled, and data collection // is disabled, the Sessions SDK still generates Session IDs and provides // them to Subscribers - @MainActor func test_subscribersDataCollectionDisabled_callsOnSessionChanged() { - runSessionsSDK( + @MainActor func test_subscribersDataCollectionDisabled_callsOnSessionChanged() async { + await runSessionsSDK( subscriberSDKs: [ mockCrashlyticsSubscriber, mockPerformanceSubscriber, @@ -86,8 +86,8 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase { ) } - @MainActor func test_noDependencies_doesNotLogSessionEvent() { - runSessionsSDK( + @MainActor func test_noDependencies_doesNotLogSessionEvent() async { + await runSessionsSDK( subscriberSDKs: [], preSessionsInit: { _ in // Nothing @@ -102,8 +102,8 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase { ) } - @MainActor func test_noSubscribersWithRegistrations_doesNotCrash() { - runSessionsSDK( + @MainActor func test_noSubscribersWithRegistrations_doesNotCrash() async { + await runSessionsSDK( subscriberSDKs: [], preSessionsInit: { _ in // Nothing diff --git a/FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift b/FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift index 7ad39614fda..dce1070cdd6 100644 --- a/FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift +++ b/FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift @@ -73,11 +73,11 @@ class FirebaseSessionsTestsBase: XCTestCase { /// most assertions will happen. @MainActor func runSessionsSDK(subscriberSDKs: [SessionsSubscriber], preSessionsInit: (MockSettingsProtocol) -> Void, - postSessionsInit: () -> Void, + postSessionsInit: () async -> Void, postLogEvent: @escaping @MainActor (Result, [SessionsSubscriber]) - -> Void) { + -> Void) async { // This class is static, so we need to clear global state SessionsDependencies.removeAll() @@ -124,11 +124,11 @@ class FirebaseSessionsTestsBase: XCTestCase { // Execute test cases after Sessions is initialized. This is a good // place register Subscriber SDKs - postSessionsInit() + await postSessionsInit() // Wait for the Sessions SDK to log the session before finishing // the test. - wait(for: [loggedEventExpectation], timeout: 3) + await fulfillment(of: [loggedEventExpectation], timeout: 3) } func assertSuccess(result: Result) { diff --git a/FirebaseSessions/Tests/Unit/SessionsStateTests.swift b/FirebaseSessions/Tests/Unit/SessionsStateTests.swift new file mode 100644 index 00000000000..029a8350e94 --- /dev/null +++ b/FirebaseSessions/Tests/Unit/SessionsStateTests.swift @@ -0,0 +1,217 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import FirebaseSessions + +/// A one-way flag used to observe whether an awaiting `Task` has resumed +/// without blocking the cooperative thread pool. +private actor Signal { + private(set) var isSet = false + + func set() { + isSet = true + } +} + +final class SessionsStateTests: XCTestCase { + /// Generous timeout for operations that are expected to complete. + private static let timeout: TimeInterval = 5 + + /// Gives any runnable `Task` ample opportunity to make progress. Used before + /// asserting that a waiter has *not* resumed, so that the assertion fails + /// loudly rather than passing because the waiter simply hadn't been + /// scheduled yet. + private static func drainScheduler() async { + for _ in 0 ..< 20 { + await Task.yield() + } + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + + // MARK: - Gate opens when there is nothing to wait for + + func test_noExpectedSubscribers_waitReturnsImmediately() async { + let state = SessionsState(expectedSubscribers: []) + + let resumed = expectation(description: "waitUntilAllRegistered returned") + Task { + let subscribers = await state.waitUntilAllRegistered() + XCTAssertTrue(subscribers.isEmpty) + resumed.fulfill() + } + + await fulfillment(of: [resumed], timeout: Self.timeout) + } + + // MARK: - Gate stays closed until every expected subscriber registers + + func test_waitDoesNotResumeUntilAllExpectedSubscribersRegister() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics, .Performance]) + + let signal = Signal() + let resumed = expectation(description: "waiter resumed once all registered") + let waiter = Task { () -> [SessionsSubscriber] in + let subscribers = await state.waitUntilAllRegistered() + await signal.set() + resumed.fulfill() + return subscribers + } + + // Only one of the two expected subscribers has registered. + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + await Self.drainScheduler() + + let resumedEarly = await signal.isSet + XCTAssertFalse( + resumedEarly, + "waitUntilAllRegistered() resumed before Performance registered" + ) + + // Completing the set must open the gate. + await state.register(subscriber: MockSubscriber(name: .Performance), name: .Performance) + await fulfillment(of: [resumed], timeout: Self.timeout) + + // The gate hands back a snapshot containing both subscribers. + let names = await Set(waiter.value.map(\.sessionsSubscriberName)) + XCTAssertEqual(names, [.Crashlytics, .Performance]) + } + + /// A subscriber that never declared itself as a dependency must not satisfy + /// the gate on its own, but it should still be reported as a subscriber. + /// This matches the pre-refactor promise-based behavior, where only expected + /// subscribers had a promise to fulfill but every registrant was appended to + /// the `subscribers` array. + func test_unexpectedSubscriber_doesNotOpenGateButIsStillTracked() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics]) + + let signal = Signal() + let resumed = expectation(description: "waiter resumed once Crashlytics registered") + let waiter = Task { () -> [SessionsSubscriber] in + let subscribers = await state.waitUntilAllRegistered() + await signal.set() + resumed.fulfill() + return subscribers + } + + await state.register(subscriber: MockSubscriber(name: .Performance), name: .Performance) + await Self.drainScheduler() + + let resumedEarly = await signal.isSet + XCTAssertFalse( + resumedEarly, + "An unexpected subscriber must not satisfy the registration gate" + ) + + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + await fulfillment(of: [resumed], timeout: Self.timeout) + + let names = await Set(waiter.value.map(\.sessionsSubscriberName)) + XCTAssertEqual(names, [.Crashlytics, .Performance]) + } + + // MARK: - Gate stays open for subsequent session starts + + /// Each app foreground beyond the session timeout starts a new session and + /// awaits the gate again. Once satisfied, the gate must never re-close. + func test_waitAfterAllRegistered_returnsImmediatelyEveryTime() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics]) + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + + for initiation in 1 ... 3 { + let resumed = expectation(description: "wait returned for initiation \(initiation)") + Task { + let subscribers = await state.waitUntilAllRegistered() + XCTAssertEqual(subscribers.count, 1) + resumed.fulfill() + } + await fulfillment(of: [resumed], timeout: Self.timeout) + } + } + + // MARK: - Every queued continuation is resumed + + func test_multipleConcurrentWaiters_allResume() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics]) + + let waiterCount = 8 + let resumed = expectation(description: "all waiters resumed") + resumed.expectedFulfillmentCount = waiterCount + + for _ in 0 ..< waiterCount { + Task { + _ = await state.waitUntilAllRegistered() + resumed.fulfill() + } + } + + // Let the waiters queue their continuations before the gate opens. + await Self.drainScheduler() + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + + await fulfillment(of: [resumed], timeout: Self.timeout) + } + + // MARK: - Registration is idempotent + + func test_duplicateRegistration_isIgnored() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics]) + + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + await state.register(subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics) + + let subscribers = await state.currentSubscribers + XCTAssertEqual( + subscribers.count, 1, + "Registering the same subscriber name twice must not duplicate it" + ) + } + + /// `Sessions.register(subscriber:)` hops onto an unstructured `Task`, so + /// registrations can arrive concurrently and out of order. The actor must + /// serialize them without losing the wakeup or duplicating subscribers. + func test_concurrentRegistrations_areSerializedAndOpenGateExactlyOnce() async { + let state = SessionsState(expectedSubscribers: [.Crashlytics, .Performance]) + + let resumed = expectation(description: "waiter resumed") + Task { + _ = await state.waitUntilAllRegistered() + resumed.fulfill() + } + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< 25 { + group.addTask { + await state.register( + subscriber: MockSubscriber(name: .Crashlytics), name: .Crashlytics + ) + } + group.addTask { + await state.register( + subscriber: MockSubscriber(name: .Performance), name: .Performance + ) + } + } + } + + await fulfillment(of: [resumed], timeout: Self.timeout) + + let subscribers = await state.currentSubscribers + XCTAssertEqual( + subscribers.count, 2, + "Concurrent duplicate registrations must be de-duplicated" + ) + } +} diff --git a/Package.swift b/Package.swift index 24ba66e72c9..711d389900a 100644 --- a/Package.swift +++ b/Package.swift @@ -1033,7 +1033,6 @@ func packageTargets() -> [Target] { // - https://github.com/firebase/firebase-ios-sdk/issues/15276 // - https://github.com/firebase/firebase-ios-sdk/pull/15287 .product(name: "nanopb", package: "nanopb"), - .product(name: "Promises", package: "Promises"), .product(name: "GoogleDataTransport", package: "GoogleDataTransport"), .product(name: "GULEnvironment", package: "GoogleUtilities"), .product(name: "GULUserDefaults", package: "GoogleUtilities"),