diff --git a/.changeset/v4-sendable-callbacks.md b/.changeset/v4-sendable-callbacks.md new file mode 100644 index 000000000..68bb07b2a --- /dev/null +++ b/.changeset/v4-sendable-callbacks.md @@ -0,0 +1,7 @@ +--- +'posthog-ios': major +--- + +**Breaking:** mark callbacks the SDK can call off the main thread `@Sendable` — `reloadFeatureFlags(_:)`, the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` used by `setBeforeSend(_:)` on `PostHogConfig` and `PostHogLogsConfig`, `PostHogConfig.getAnonymousId`, `PostHogConfig.pushIdentityProvider` and its `completion`, and `sessionReplayConfig.captureLogsConfig.logSanitizer` — and make `PostHogFeatureFlagsLoaded` `Sendable`. This fixes a crash in Swift 6 apps when a `reloadFeatureFlags(_:)` callback is written in main-actor code. + +To update main-actor state from `reloadFeatureFlags(_:)` or `pushIdentityProvider`, hop with `Task { @MainActor in ... }`. The other callbacks return a value immediately, so they must not read main-actor state. Declare closures you store in your own variables as `@Sendable` to avoid new Swift 5 warnings. Use the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` typealiases for any `setBeforeSend(_:)` arrays you build or pass around, including your own helpers: in Swift 5 mode, converting an array between the old function type and these types compiles but crashes at runtime, in either direction. diff --git a/Package.swift b/Package.swift index 0c095023b..db82f2080 100644 --- a/Package.swift +++ b/Package.swift @@ -102,6 +102,15 @@ let package = Package( .process("Resources"), ] ), + // Compiles in Swift 6 language mode to check the public API the way a Swift 6 app sees it. + .testTarget( + name: "PostHogSwift6Tests", + dependencies: ["PostHog"], + path: "PostHogSwift6Tests", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), ], swiftLanguageModes: [.v5] ) diff --git a/PostHog/Logs/PostHogLogsConfig.swift b/PostHog/Logs/PostHogLogsConfig.swift index 2a5d11c8b..99b4fc272 100644 --- a/PostHog/Logs/PostHogLogsConfig.swift +++ b/PostHog/Logs/PostHogLogsConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter record: The log record about to be queued. /// - Returns: The record to queue, or `nil` to drop it. -public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord? +public typealias PostHogBeforeSendLogBlock = @Sendable (PostHogLogRecord) -> PostHogLogRecord? /// Configuration for the PostHog logs subsystem. Mutate fields on `config.logs` /// before calling `PostHogSDK.setup(_:)`. @@ -105,7 +105,7 @@ public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRec @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) { setBeforeSend(blocks.map { box in - { record in box.invokeSafely(with: record) } + { @Sendable record in box.invokeSafely(with: record) } }) } diff --git a/PostHog/Models/PostHogFeatureFlagsLoaded.swift b/PostHog/Models/PostHogFeatureFlagsLoaded.swift index 4ce21c835..1782054cb 100644 --- a/PostHog/Models/PostHogFeatureFlagsLoaded.swift +++ b/PostHog/Models/PostHogFeatureFlagsLoaded.swift @@ -22,7 +22,9 @@ import Foundation /// subscription.unsubscribe() /// ``` @objc(PostHogFeatureFlagsLoaded) -public final class PostHogFeatureFlagsLoaded: NSObject { +public final class PostHogFeatureFlagsLoaded: NSObject, @unchecked Sendable { + // @unchecked: every property is a `let`, and `variants` only holds Swift `Bool` and `String` values (see init). + /// The keys of the enabled feature flags. @objc public let flags: [String] @@ -38,11 +40,16 @@ public final class PostHogFeatureFlagsLoaded: NSObject { @objc public let errorsLoading: Bool init(featureFlags: [String: Any], errorsLoading: Bool) { - let enabled = featureFlags.filter { _, value in + // Store the bridged values, not the originals: a bootstrap `NSMutableString` would stay mutable. + var enabled: [String: Any] = [:] + for (key, value) in featureFlags { if let bool = value as? Bool { - return bool + if bool { + enabled[key] = bool + } + } else if let string = value as? String { + enabled[key] = string } - return value is String } flags = Array(enabled.keys) variants = enabled diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index e8baf0cc0..85f54a007 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter event: The event about to be queued. /// - Returns: The event to queue, or `nil` to drop it. -public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? +public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Runtime configuration for a `PostHogSDK` instance. /// @@ -205,7 +205,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread). /// /// Default: `nil` (requests carry no identity token). - @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + @objc public var pushIdentityProvider: (@Sendable (_ distinctId: String, _ appId: String, _ completion: @escaping @Sendable (String?) -> Void) -> Void)? #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. @@ -282,11 +282,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Hook used to customize newly generated anonymous IDs. /// /// The SDK passes its generated UUID v7 and stores the UUID returned by this closure. - /// Existing stored anonymous IDs are not regenerated. + /// Existing stored anonymous IDs are not regenerated. The closure can be called on any thread, + /// so it must not read main-actor state. /// /// - Parameter uuid: The SDK-generated anonymous UUID. /// - Returns: The UUID to persist as the anonymous ID. - @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + @objc public var getAnonymousId: (@Sendable (UUID) -> UUID) = { uuid in uuid } /// Pre-seeded identity and feature-flag state applied during setup, before any /// network request completes. @@ -604,7 +605,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) { setBeforeSend(blocks.map { box in - { event in box.invokeSafely(with: event) } + { @Sendable event in box.invokeSafely(with: event) } }) } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index ca5d78ef0..84f077925 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2322,9 +2322,10 @@ let maxRetryDelay = 30.0 /// - Parameter callback: Invoked when the reload finishes, possibly on a background thread. /// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are /// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked - /// right away with no flags and `errorsLoading` set to `true`. + /// right away with no flags and `errorsLoading` set to `true`. To update UI or other main-actor + /// state, hop to the main actor, for example with `Task { @MainActor in ... }`. @objc(reloadFeatureFlagsWithCallback:) - public func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) { + public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) { if !isEnabled() { callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 198c96bb5..e0bb2748b 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -697,7 +697,8 @@ final class PostHogPushSubscriptionHandler { return completion(cached) } - var completed = false + // Guarded by stateLock. + nonisolated(unsafe) var completed = false // A provider that never calls its completion would hold isSending for the whole process and // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a // token-less send. A late real completion doesn't deliver (via `completed`) but still diff --git a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift index 48d498eb2..a77c6a33d 100644 --- a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift @@ -23,9 +23,11 @@ /// - Detect log level (best effort) /// - Process OSLog messages to remove metadata /// + /// The block runs on a background thread, so it must not read main-actor state. + /// /// - Parameter output: The raw console output to process. /// - Returns: A `PostHogConsoleLogEntry` to capture, or `nil` to skip the log output. - @objc public var logSanitizer: ((String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer + @objc public var logSanitizer: (@Sendable (String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer /// The minimum log level to capture in session replay. /// Only log messages with this level or higher will be captured. @@ -38,7 +40,7 @@ @objc public var minLogLevel: PostHogConsoleLogLevel = .error /// Default implementation for processing console output. - static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { + @Sendable static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { let message = String(message) // Determine console log level let level: PostHogConsoleLogLevel = { diff --git a/PostHog/Utils/BeforeSendChain.swift b/PostHog/Utils/BeforeSendChain.swift index 446ad4cc2..098c1b084 100644 --- a/PostHog/Utils/BeforeSendChain.swift +++ b/PostHog/Utils/BeforeSendChain.swift @@ -5,11 +5,13 @@ import Foundation -/// Composable `(T) -> T?` pipeline shared by the events and logs configs. +/// Composable `@Sendable (T) -> T?` pipeline shared by the events and logs configs. /// Blocks run in registration order; the first `nil` drops the value. /// Reference type so concurrent `set` / `run` see a coherent block snapshot. final class BeforeSendChain { - typealias Block = (T) -> T? + // @Sendable to match the public block types: converting their arrays to a + // non-Sendable element type fails at runtime (`_arrayForceCast`). + typealias Block = @Sendable (T) -> T? private let lock = NSLock() private var block: Block = { $0 } diff --git a/PostHog/Utils/BoxedBeforeSend.swift b/PostHog/Utils/BoxedBeforeSend.swift index f67fbfe1c..f3c29e862 100644 --- a/PostHog/Utils/BoxedBeforeSend.swift +++ b/PostHog/Utils/BoxedBeforeSend.swift @@ -13,7 +13,7 @@ internal import PostHogObjCExceptionSupport /// ObjC wrapper for the events `beforeSend` block. Use with /// `PostHogConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendBlock: NSObject { +@objc public final class BoxedBeforeSendBlock: NSObject, Sendable { /// Wrapped event callback. @objc public let block: BeforeSendBlock @@ -35,7 +35,7 @@ internal import PostHogObjCExceptionSupport /// ObjC wrapper for the logs `beforeSend` block. Use with /// `PostHogLogsConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendLogBlock: NSObject { +@objc public final class BoxedBeforeSendLogBlock: NSObject, Sendable { /// Wrapped log callback. @objc public let block: PostHogBeforeSendLogBlock diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index cdb969a0b..9a5fc9cbe 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -36,6 +36,7 @@ class SignInViewModel: NSObject, ObservableObject, ASWebAuthenticationPresentati } } +@MainActor class FeatureFlagsModel: ObservableObject { @Published var boolValue: Bool? @Published var stringValue: String? @@ -56,7 +57,10 @@ class FeatureFlagsModel: ObservableObject { isReloading = true PostHogSDK.shared.reloadFeatureFlags { _ in - self.isReloading = false + // The callback can run on a background thread; @Published changes belong on main. + Task { @MainActor in + self.isReloading = false + } } } } diff --git a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift new file mode 100644 index 000000000..d9b646a4e --- /dev/null +++ b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift @@ -0,0 +1,103 @@ +// +// PostHogCallbackIsolationTests.swift +// PostHogSwift6Tests +// + +import Foundation +import PostHog +import Testing + +/// Calls the public callback APIs from main-actor code, as a Swift 6 app would. +/// +/// In Swift 6 mode, a closure written in main-actor code and passed as a non-`Sendable` +/// parameter is main-actor isolated, and the runtime traps if the SDK calls it off the main thread. +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct PostHogCallbackIsolationTests { + @MainActor final class AppState { + var reloadResult: PostHogFeatureFlagsLoaded? + var loaded: PostHogFeatureFlagsLoaded? + var deliveredOnMain = false + } + + /// Nothing listens on this port, so requests fail fast without leaving the machine. + private func makeSDK(bootstrapFlags: [String: Any]? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_swift6_\(UUID().uuidString)", host: "http://127.0.0.1:9") + config.captureApplicationLifecycleEvents = false + config.preloadFeatureFlags = false + if let bootstrapFlags { + let bootstrap = PostHogBootstrapConfig() + bootstrap.featureFlags = bootstrapFlags + config.bootstrap = bootstrap + } + return PostHogSDK.with(config) + } + + @Test("reloadFeatureFlags callback written in main-actor code runs without an isolation trap") + func reloadCallbackFromMainActorCode() async { + let sdk = makeSDK() + defer { sdk.close() } + let state = AppState() + + // The SDK invokes this from its network completion, off the main thread. + await withCheckedContinuation { continuation in + sdk.reloadFeatureFlags { result in + // Sending the result into the hop requires it to be Sendable. + Task { @MainActor in + state.reloadResult = result + continuation.resume() + } + } + } + + #expect(state.reloadResult != nil) + } + + @Test("onFeatureFlags callback written in main-actor code runs on main with a Sendable payload") + func onFeatureFlagsDeliversOnMainActor() async { + let sdk = makeSDK(bootstrapFlags: ["swift6-flag": true]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + // Touches main-actor state directly, with no hop. + guard state.loaded == nil else { return } + state.loaded = loaded + state.deliveredOnMain = Thread.isMainThread + continuation.resume() + } + } + subscription?.unsubscribe() + + #expect(state.deliveredOnMain) + // Sending the payload to another task requires it to be Sendable. + let loaded = state.loaded + let flags = await Task.detached { loaded?.flags ?? [] }.value + #expect(flags == ["swift6-flag"]) + } + + @Test("onFeatureFlags payload keeps its variant values when a mutable bootstrap value changes") + func onFeatureFlagsPayloadIsImmutable() async { + let source = NSMutableString(string: "original") + let sdk = makeSDK(bootstrapFlags: ["swift6-variant": source]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + guard state.loaded == nil else { return } + state.loaded = loaded + continuation.resume() + } + } + subscription?.unsubscribe() + + source.setString("changed") + let loaded = state.loaded + let variant = await Task.detached { loaded?.variants["swift6-variant"] as? String }.value + #expect(variant == "original") + } +} diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 75787a27a..68fa353c9 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -72,8 +72,8 @@ PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureF PostHog | PostHogConfig.sessionReplay | property | @objc var sessionReplay: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplay PostHog | PostHogConfig.sessionReplayConfig | property | @objc let sessionReplayConfig: PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplayConfig PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFcd_tF -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties PostHog | PostHogConfig.surveys | property | @objc var surveys: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveys PostHog | PostHogConfig.surveysConfig | property | @objc var surveysConfig: PostHogSurveysConfig { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveysConfig @@ -230,8 +230,8 @@ PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAt PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFcd_tF -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF PostHog | PostHogNextSurveyQuestion | class | @objc class PostHogNextSurveyQuestion | c:@M@PostHog@objc(cs)PostHogNextSurveyQuestion PostHog | PostHogNextSurveyQuestion.isSurveyCompleted | property | let isSurveyCompleted: Bool | s:7PostHog0aB18NextSurveyQuestionC02isD9CompletedSbvp PostHog | PostHogNextSurveyQuestion.questionIndex | property | let questionIndex: Int | s:7PostHog0aB18NextSurveyQuestionC13questionIndexSivp