From abab5e18c01f0f67de15a61108e1553a375a90a9 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 24 Jul 2026 18:27:02 +0300 Subject: [PATCH 01/12] feat: attach identity verification token to push subscription requests --- .changeset/eighty-lions-arrive.md | 5 + PostHog/PostHogApi.swift | 16 +- PostHog/PostHogConfig.swift | 15 ++ PostHog/PostHogSDK.swift | 2 + .../PostHogPushSubscriptionHandler.swift | 125 +++++++++- PostHogTests/PostHogApiTest.swift | 36 ++- PostHogTests/PostHogConfigTest.swift | 1 + .../PostHogPushNotificationTest.swift | 236 +++++++++++++++++- .../TestUtils/MockPostHogServer.swift | 8 +- api/posthog-ios.public-api.txt | 1 + 10 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 .changeset/eighty-lions-arrive.md diff --git a/.changeset/eighty-lions-arrive.md b/.changeset/eighty-lions-arrive.md new file mode 100644 index 000000000..8db544bc1 --- /dev/null +++ b/.changeset/eighty-lions-arrive.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": minor +--- + +Add `PostHogConfig.pushIdentityProvider` so apps can attach a backend-minted identity token (`identity_token`) to push subscription register/unregister requests, for projects that enable identity verification on their push integration. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 09521f28d..4dbb1daa6 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -245,11 +245,13 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { sendPushSubscription( httpMethod: "POST", endpointName: "push subscription", - distinctId: distinctId, deviceToken: deviceToken, appId: appId, completion: completion + distinctId: distinctId, deviceToken: deviceToken, appId: appId, + identityToken: identityToken, completion: completion ) } @@ -260,11 +262,13 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { sendPushSubscription( httpMethod: "DELETE", endpointName: "push unsubscription", - distinctId: distinctId, deviceToken: deviceToken, appId: appId, completion: completion + distinctId: distinctId, deviceToken: deviceToken, appId: appId, + identityToken: identityToken, completion: completion ) } @@ -274,6 +278,7 @@ class PostHogApi { distinctId: String, deviceToken: String, appId: String, + identityToken: String?, completion: @escaping (PostHogUploadInfo) -> Void ) { guard let url = getEndpointURL("/api/push_subscriptions/", relativeTo: config.host) else { @@ -281,13 +286,18 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - let toSend: [String: Any] = [ + // The token key is omitted entirely when absent — the backend treats a missing key as + // "unsigned"; an explicit null would fail its string check. + var toSend: [String: Any] = [ "api_key": config.projectToken, "distinct_id": distinctId, "device_token": deviceToken, "platform": "ios", "app_id": appId, ] + if let identityToken { + toSend["identity_token"] = identityToken + } guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { hedgeLog("Error parsing the \(endpointName) body") diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index c82c771f0..694baa9b5 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -185,6 +185,21 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @objc public var capturePushNotificationOpened: Bool = true #endif + /// Supplies a signed identity token to attach (as `identity_token`) to push subscription + /// register/unregister requests, for projects that enable identity verification on their push + /// integration. Invoked with the `distinct_id` and `app_id` the request carries; call `completion` + /// **exactly once**, from any thread, with a token minted by your backend — or `nil` to send the + /// request without one. The SDK never mints tokens itself: your backend signs an HS256 JWT with + /// the project's **secret** API key, claims `sub` = distinct id, `app_id`, + /// `aud` = "posthog:push_identity", and an `exp`. + /// + /// The token is cached in memory per `(distinctId, appId)` and re-requested on identity change, on + /// the next launch, and once after a 401 rejection. An uncalled `completion` strands that request + /// until the next launch. + /// + /// Default: `nil` (requests carry no identity token). + @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. /// diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 622f633cb..a3dd57318 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2398,6 +2398,8 @@ let maxRetryDelay = 30.0 storage?.setBool(forKey: .optOut, contents: true) } + pushSubscriptionHandler?.onOptOut() + setupLock.withLock { uninstallIntegrations() } diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 00d6b678d..1cb26fcd4 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -20,6 +20,12 @@ final class PostHogPushSubscriptionHandler { let deliveredForDistinctId: String? } + private struct CachedIdentityToken { + let token: String + let distinctId: String + let appId: String + } + private static let firstRetryDelay: TimeInterval = 5 private static let maxRetryDelay: TimeInterval = 30 @@ -32,7 +38,8 @@ final class PostHogPushSubscriptionHandler { /// SDK is disabled or opted out. The record is kept so an opt-in can resume later. private let isAllowedProvider: () -> Bool - /// Guards `retryCount`, `pausedUntil`, `isSending`, `pendingResend`, and `halted`. + /// Guards `retryCount`, `pausedUntil`, `isSending`, `pendingResend`, `halted`, + /// `cachedIdentityToken`, and `didAuthRetry`. private let stateLock = NSLock() private var retryCount = 0 private var pausedUntil: Date? @@ -43,6 +50,12 @@ final class PostHogPushSubscriptionHandler { /// Set after a non-retryable failure or once retries are exhausted: no more attempts this session, /// but the record is kept for one retry on the next launch. private var halted = false + /// Last token minted by `config.pushIdentityProvider` for a `(distinctId, appId)` pair, reused by + /// retries and reset() to avoid extra provider roundtrips; cleared on a 401 re-mint and opt-out. In memory + /// only — unlike the persisted record, a short-lived credential must not outlive the process. + private var cachedIdentityToken: CachedIdentityToken? + /// One fresh-token retry per send-cycle after a 401; reset with the retry state. + private var didAuthRetry = false /// Serializes storage-record read-modify-writes (persist, deliver-stamp, unregister-clear, reset /// re-register) so a concurrent `send()`, `reset()`, or send completion can't interleave and lose a @@ -145,11 +158,18 @@ final class PostHogPushSubscriptionHandler { hedgeLog("Push unregister skipped: missing distinct id, token, or app id.") return } - api.deletePushSubscription(distinctId: distinctId, deviceToken: deviceToken, appId: appId) { info in - if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { - hedgeLog("Push subscription unregistered successfully.") - } else { - hedgeLog("Push unregister failed (status \(info.statusCode.map(String.init) ?? "none")); ignoring (best-effort).") + // Same one-verification-state as registration: the DELETE resolves a token for the distinct id + // it carries (the old identity on the reset() path). Still best-effort — no 401 refresh here. + resolveIdentityToken(distinctId: distinctId, appId: appId) { [weak self] identityToken in + guard let self, isAllowedProvider() else { return } + api.deletePushSubscription( + distinctId: distinctId, deviceToken: deviceToken, appId: appId, identityToken: identityToken + ) { info in + if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { + hedgeLog("Push subscription unregistered successfully.") + } else { + hedgeLog("Push unregister failed (status \(info.statusCode.map(String.init) ?? "none")); ignoring (best-effort).") + } } } } @@ -196,6 +216,11 @@ final class PostHogPushSubscriptionHandler { unregister(distinctId: distinctIdProvider(), deviceToken: record.deviceToken, appId: record.appId) } + /// Opt-out drops the cached identity credential so a later opt-in re-mints it. + func onOptOut() { + stateLock.withLock { cachedIdentityToken = nil } + } + // MARK: - Private private func resendIfDistinctIdChanged(currentDistinctId: String) { @@ -216,6 +241,7 @@ final class PostHogPushSubscriptionHandler { retryCount = 0 pausedUntil = nil halted = false + didAuthRetry = false } } @@ -254,8 +280,61 @@ final class PostHogPushSubscriptionHandler { } guard shouldSend else { return } - api.pushSubscription(distinctId: distinctId, deviceToken: deviceToken, appId: appId) { [weak self] info in - self?.handleResult(info, deviceToken: deviceToken, appId: appId, distinctId: distinctId) + // `isSending` stays claimed across the mint, so a registration arriving mid-mint folds into + // `pendingResend` exactly like one arriving mid-request. + resolveIdentityToken(distinctId: distinctId, appId: appId) { [weak self] identityToken in + guard let self else { return } + // Opt-out may land during a slow mint; re-check before sending. + guard isAllowedProvider() else { + stateLock.withLock { self.isSending = false } + return + } + api.pushSubscription( + distinctId: distinctId, deviceToken: deviceToken, appId: appId, identityToken: identityToken + ) { [weak self] info in + self?.handleResult(info, deviceToken: deviceToken, appId: appId, distinctId: distinctId) + } + } + } + + /// Resolves the identity token for `distinctId`/`appId`, preferring an exact cache hit. The + /// completion may run synchronously or from whatever thread the provider completes on; extra + /// provider completions are ignored (an uncalled one strands the request — see the config doc). + private func resolveIdentityToken(distinctId: String, appId: String, completion: @escaping (String?) -> Void) { + guard let provider = config.pushIdentityProvider else { + hedgeLog("Push subscription request sent without identity token (no pushIdentityProvider).") + return completion(nil) + } + + let cached = stateLock.withLock { () -> String? in + guard let cache = cachedIdentityToken, cache.distinctId == distinctId, cache.appId == appId else { + return nil + } + return cache.token + } + if let cached { + hedgeLog("Push subscription request sent with cached identity token.") + return completion(cached) + } + + var completed = false + provider(distinctId, appId) { [weak self] token in + guard let self else { return } + let isFirst = self.stateLock.withLock { () -> Bool in + if completed { + return false + } + completed = true + if let token { + self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) + } + return true + } + guard isFirst else { return } + hedgeLog(token != nil + ? "Push subscription request sent with freshly minted identity token." + : "Push subscription request sent without identity token (provider completed nil).") + completion(token) } } @@ -272,7 +351,7 @@ final class PostHogPushSubscriptionHandler { resetRetryState() hedgeLog("Push subscription sent successfully.") } else { - handleFailure(info) + handleFailure(info, deviceToken: deviceToken, appId: appId) } // A newer registration or identity change arrived while this send was in flight. Service it with @@ -285,7 +364,25 @@ final class PostHogPushSubscriptionHandler { /// Applies backoff/halt state for a non-2xx result. Split from `handleResult` so a coalesced resend /// is serviced afterward regardless of which failure branch this took. - private func handleFailure(_ info: PostHogUploadInfo) { + private func handleFailure(_ info: PostHogUploadInfo, deviceToken: String, appId: String) { + // A 401 means identity verification failed. With a provider, allow one fresh-token retry per + // send-cycle; a second 401 falls through to the terminal branch below. + if info.statusCode == 401, config.pushIdentityProvider != nil { + let shouldRetryWithFreshToken = stateLock.withLock { () -> Bool in + if didAuthRetry { + return false + } + didAuthRetry = true + cachedIdentityToken = nil + return true + } + if shouldRetryWithFreshToken { + hedgeLog("Push subscription rejected (401). Retrying once with a fresh identity token.") + attemptIfAllowed(deviceToken: deviceToken, appId: appId) + return + } + } + // Retryable: transport error (no status), 429, or 5xx. Everything else (400/401) is terminal. let retryable: Bool if let statusCode = info.statusCode { @@ -296,7 +393,13 @@ final class PostHogPushSubscriptionHandler { guard retryable else { stateLock.withLock { halted = true } - hedgeLog("Push subscription rejected (status \(info.statusCode.map(String.init) ?? "none")). Keeping record for next launch.") + if info.statusCode == 401, config.pushIdentityProvider == nil { + hedgeLog( + "Push subscription rejected (401): identity verification may be required — set config.pushIdentityProvider. Keeping record for next launch." + ) + } else { + hedgeLog("Push subscription rejected (status \(info.statusCode.map(String.init) ?? "none")). Keeping record for next launch.") + } return } diff --git a/PostHogTests/PostHogApiTest.swift b/PostHogTests/PostHogApiTest.swift index 32eb9a3c6..891708b01 100644 --- a/PostHogTests/PostHogApiTest.swift +++ b/PostHogTests/PostHogApiTest.swift @@ -97,6 +97,7 @@ enum PostHogApiTests { distinctId: "test-user", deviceToken: "abc123", appId: "com.example.app", + identityToken: nil, completion: completion ) } @@ -114,6 +115,7 @@ enum PostHogApiTests { distinctId: "user-42", deviceToken: "deadbeef", appId: "com.example.app", + identityToken: nil, completion: completion ) } @@ -129,6 +131,38 @@ enum PostHogApiTests { #expect(body["app_id"] as? String == "com.example.app") } + /// Shared test vector 9 at the API layer: a provided identity token is serialized as + /// `identity_token` alongside the unchanged five fields, on POST and DELETE alike. + @Test("push subscription bodies carry identity_token when provided") + func pushSubscriptionBodyIdentityToken() async throws { + let sut = getSut(host: "http://localhost") + let _: PostHogUploadInfo = await getApiResponse { completion in + sut.pushSubscription( + distinctId: "user-42", + deviceToken: "deadbeef", + appId: "com.example.app", + identityToken: "jwt-abc", + completion: completion + ) + } + let _: PostHogUploadInfo = await getApiResponse { completion in + sut.deletePushSubscription( + distinctId: "user-42", + deviceToken: "deadbeef", + appId: "com.example.app", + identityToken: "jwt-abc", + completion: completion + ) + } + + #expect(server.pushSubscriptionRequests.count == 2) + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(Set(body.keys) == ["api_key", "distinct_id", "device_token", "platform", "app_id", "identity_token"]) + #expect(body["identity_token"] as? String == "jwt-abc") + } + } + func getSut(host: String) -> PostHogApi { PostHogApi(PostHogConfig(projectToken: "test_project_token", host: host)) } @@ -287,7 +321,7 @@ enum PostHogApiTests { func pushSubscriptionDeclaresGzip() async throws { let sut = getSut(host: "http://localhost") let _: PostHogUploadInfo = await getApiResponse { completion in - sut.pushSubscription(distinctId: "x", deviceToken: "tok", appId: "app", completion: completion) + sut.pushSubscription(distinctId: "x", deviceToken: "tok", appId: "app", identityToken: nil, completion: completion) } let request = try #require(server.pushSubscriptionRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index 5d7b176e3..62727d56a 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -32,6 +32,7 @@ class PostHogConfigTest: QuickSpec { expect(config.capturePushNotificationSubscriptions) == true expect(config.capturePushNotificationOpened) == true #endif + expect(config.pushIdentityProvider).to(beNil()) } it("init takes project token") { diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index e468d8194..09b721271 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -32,6 +32,23 @@ // MARK: - Helpers + /// Thread-safe recorder for `pushIdentityProvider` closures, which may run on any thread. + private final class MintRecorder { + private let lock = NSLock() + private var invocations = [(distinctId: String, appId: String)]() + + /// Records one invocation and returns its 1-based ordinal (usable as a unique token suffix). + func record(_ distinctId: String, _ appId: String) -> Int { + lock.withLock { + invocations.append((distinctId, appId)) + return invocations.count + } + } + + var count: Int { lock.withLock { invocations.count } } + var distinctIds: [String] { lock.withLock { invocations.map(\.distinctId) } } + } + private func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = 5) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -84,13 +101,15 @@ optOut: Bool = false, enableSwizzling: Bool = true, capturePushNotificationOpened: Bool = false, - reuseAnonymousId: Bool = false + reuseAnonymousId: Bool = false, + pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? = nil ) -> PostHogSDK { let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9001") config.flushAt = 1 config.optOut = optOut config.reuseAnonymousId = reuseAnonymousId config.enableSwizzling = enableSwizzling + config.pushIdentityProvider = pushIdentityProvider config.captureApplicationLifecycleEvents = false config.captureScreenViews = false config.capturePushNotificationSubscriptions = false @@ -318,6 +337,50 @@ try? await Task.sleep(nanoseconds: 250_000_000) #expect(!server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" }) } + + @Test("reset(): DELETE reuses the old id's cached token, re-POST mints the anon id's (vector 11)") + func resetMintsPerLegIdentityTokens() async throws { + let recorder = MintRecorder() + let sut = getSDK(pushIdentityProvider: { distinctId, appId, completion in + _ = recorder.record(distinctId, appId) + completion("jwt-\(distinctId)") + }) + defer { sut.close() } + + sut.identify("user-A") + sut.registerPushNotificationToken("tokA", appId: "com.example.app") + #expect(await waitFor { + (sut.storage?.getDictionary(forKey: .pushSubscription) as? [String: String])?["deliveredForDistinctId"] == "user-A" + }) + + // identify() re-registers under the new id (decision 5), minting user-B's token. + sut.identify("user-B") + #expect(await waitFor { + (sut.storage?.getDictionary(forKey: .pushSubscription) as? [String: String])?["deliveredForDistinctId"] == "user-B" + }) + server.pushSubscriptionRequests = [] + + sut.reset() + + #expect(await waitFor { + self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } + && self.server.pushSubscriptionRequests.contains { $0.httpMethod == "POST" } + }) + + // The DELETE reuses user-B's token cached at identify time; only the anon re-POST + // mints. One provider invocation per distinct id across the scenario. + let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) + let delBody = try #require(server.parseRequest(del)) + #expect(delBody["distinct_id"] as? String == "user-B") + #expect(delBody["identity_token"] as? String == "jwt-user-B") + + let post = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + let postBody = try #require(server.parseRequest(post)) + let anonId = try #require(postBody["distinct_id"] as? String) + #expect(anonId != "user-B") + #expect(postBody["identity_token"] as? String == "jwt-\(anonId)") + #expect(recorder.distinctIds == ["user-A", "user-B", anonId]) + } #endif @Test("reset() sends no push requests when no token was ever registered") @@ -479,6 +542,177 @@ #expect(!delivered(storage)) } + // MARK: - Identity verification (vectors 9–14) + + @Test("provider token lands as identity_token on register POST and unregister DELETE (vector 9)") + func identityTokenOnRegisterAndUnregister() async throws { + let (handler, storage, config) = makeHandler(distinctIdProvider: { "user-1" }) + // Complete from a foreign thread — the provider contract allows any thread. + config.pushIdentityProvider = { _, _, completion in + DispatchQueue.global().async { completion("jwt-abc") } + } + + handler.send(deviceToken: "tok", appId: "com.example.app") + #expect(await waitFor { self.delivered(storage) }) + + let post = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + let postBody = try #require(server.parseRequest(post)) + #expect(postBody["identity_token"] as? String == "jwt-abc") + #expect(postBody["api_key"] as? String == testProjectToken) + #expect(postBody["distinct_id"] as? String == "user-1") + #expect(postBody["device_token"] as? String == "tok") + #expect(postBody["platform"] as? String == "ios") + #expect(postBody["app_id"] as? String == "com.example.app") + + handler.unregisterCurrentToken() + + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) + #expect(try #require(server.parseRequest(del))["identity_token"] as? String == "jwt-abc") + } + + @Test("no provider: bodies contain no identity_token key (vector 10)") + func noProviderOmitsIdentityToken() async throws { + let (handler, storage, _) = makeHandler(distinctIdProvider: { "user-1" }) + + handler.send(deviceToken: "tok", appId: "com.example.app") + #expect(await waitFor { self.delivered(storage) }) + handler.unregisterCurrentToken() + #expect(await waitFor { self.server.pushSubscriptionRequests.count == 2 }) + + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(!body.keys.contains("identity_token")) + } + } + + @Test("provider completing nil: body contains no identity_token key (vector 10)") + func nilCompletionOmitsIdentityToken() async throws { + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { _, _, completion in completion(nil) } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + let body = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(!body.keys.contains("identity_token")) + } + + @Test("only the first provider completion is honored") + func onlyFirstProviderCompletionHonored() async throws { + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { _, _, completion in + completion("jwt-first") + completion("jwt-second") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 1) + let body = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(body["identity_token"] as? String == "jwt-first") + } + + @Test("500 then 200: provider minted once, both attempts carry the same token (vector 12)") + func retryReusesCachedIdentityToken() async throws { + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 500 : 200 } + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { handler.retryCountForTesting == 1 }) + + handler.clearBackoffForTesting() + handler.retryIfNeeded() + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 1) + for request in server.pushSubscriptionRequests { + let body = try #require(server.parseRequest(request)) + #expect(body["identity_token"] as? String == "jwt-mint-1") + } + } + + @Test("401 then 200: one fresh-token retry with a re-minted token succeeds (vector 13)") + func authRetryWithFreshTokenSucceeds() async throws { + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 401 : 200 } + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.delivered(storage) }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 2) + let first = try #require(server.parseRequest(server.pushSubscriptionRequests[0])) + #expect(first["identity_token"] as? String == "jwt-mint-1") + let second = try #require(server.parseRequest(server.pushSubscriptionRequests[1])) + #expect(second["identity_token"] as? String == "jwt-mint-2") + } + + @Test("401 twice: terminal after exactly two requests and two mints, record kept (vector 13)") + func secondAuthFailureIsTerminal() async throws { + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + let (handler, storage, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { handler.isHaltedForTesting }) + #expect(server.pushSubscriptionRequests.count == 2) + #expect(recorder.count == 2) + + // No further in-session attempts while halted. + handler.clearBackoffForTesting() + handler.retryIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.count == 2) + + #expect(record(storage)?["deviceToken"] == "tok") + #expect(!delivered(storage)) + } + + @Test("401 with no provider: terminal after one request, record kept (vector 14)") + func unauthorizedWithoutProviderIsTerminal() async throws { + server.pushSubscriptionStatusCode = 401 + let (handler, storage, _) = makeHandler() + + handler.send(deviceToken: "tok", appId: "app") + + #expect(await waitFor { handler.isHaltedForTesting }) + #expect(server.pushSubscriptionRequests.count == 1) + #expect(record(storage)?["deviceToken"] == "tok") + #expect(!delivered(storage)) + } + + @Test("unregister DELETE gets no 401 fresh-token retry (best-effort, single-shot)") + func unregisterNoAuthRetry() async throws { + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + let (handler, _, config) = makeHandler(distinctIdProvider: { "user-1" }) + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 1) + #expect(recorder.count == 1) + } + // MARK: - Offline @Test("defers while offline without burning a retry attempt") diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 8d4db6008..b0e169356 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -30,6 +30,9 @@ class MockPostHogServer { /// When set, `/push_subscriptions` replies with this exact status code (takes precedence over the /// 500 toggles). Used to exercise non-retryable responses like 400. var pushSubscriptionStatusCode: Int? + /// When set, picks the `/push_subscriptions` status per request (1-based request number); takes + /// precedence over all fixed toggles. Used for scripted sequences (e.g. 401 then 200). + var pushSubscriptionStatusHandler: ((Int) -> Int)? /// When set, `/push_subscriptions` responses carry this `Retry-After` header value. var pushSubscriptionRetryAfter: String? private var stubDescriptors = [HTTPStubsDescriptor]() @@ -365,7 +368,9 @@ class MockPostHogServer { self.pushSubscriptionRequests.append(request) let status: Int - if let code = self.pushSubscriptionStatusCode { + if let handler = self.pushSubscriptionStatusHandler { + status = handler(self.pushSubscriptionRequests.count) + } else if let code = self.pushSubscriptionStatusCode { status = code } else if self.return500 || self.returnPushSubscription500 { status = 500 @@ -526,6 +531,7 @@ class MockPostHogServer { batchResponseHandler = nil returnPushSubscription500 = false pushSubscriptionStatusCode = nil + pushSubscriptionStatusHandler = nil pushSubscriptionRetryAfter = nil } diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 553ec4281..2f1ae4036 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -83,6 +83,7 @@ PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: Po PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.propertiesSanitizer | property | @objc var propertiesSanitizer: (any PostHogPropertiesSanitizer)? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)propertiesSanitizer +PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.remoteConfig | property | @objc var remoteConfig: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)remoteConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders From 732de2228def132a3966c57f5f6966cd78ab9cb9 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 13:02:01 +0300 Subject: [PATCH 02/12] chore: drop changeset until release --- .changeset/eighty-lions-arrive.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/eighty-lions-arrive.md diff --git a/.changeset/eighty-lions-arrive.md b/.changeset/eighty-lions-arrive.md deleted file mode 100644 index 8db544bc1..000000000 --- a/.changeset/eighty-lions-arrive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-ios": minor ---- - -Add `PostHogConfig.pushIdentityProvider` so apps can attach a backend-minted identity token (`identity_token`) to push subscription register/unregister requests, for projects that enable identity verification on their push integration. From e1d46af63c86e7e8511bf7f189c2c3f36e204a27 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 13:09:59 +0300 Subject: [PATCH 03/12] fix: do not cache identity token minted after opt-out --- .../PostHogPushSubscriptionHandler.swift | 5 +++- .../PostHogPushNotificationTest.swift | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 1cb26fcd4..30fde2aae 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -320,12 +320,15 @@ final class PostHogPushSubscriptionHandler { var completed = false provider(distinctId, appId) { [weak self] token in guard let self else { return } + // A mint can complete after opt-out cleared the cache; caching it would resurrect a + // stale credential on a later opt-in. The 401 refresh covers the residual race window. + let allowed = isAllowedProvider() let isFirst = self.stateLock.withLock { () -> Bool in if completed { return false } completed = true - if let token { + if let token, allowed { self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) } return true diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 09b721271..1022a0784 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -614,6 +614,32 @@ #expect(body["identity_token"] as? String == "jwt-first") } + @Test("a mint completing after opt-out is not cached; opt-in re-mints") + func lateMintAfterOptOutNotCached() async throws { + var allowed = true + var pendingCompletion: ((String?) -> Void)? + var mints = 0 + let (handler, storage, config) = makeHandler(isAllowedProvider: { allowed }) + config.pushIdentityProvider = { _, _, completion in + mints += 1 + pendingCompletion = completion + } + + handler.send(deviceToken: "tok", appId: "app") + allowed = false + handler.onOptOut() + pendingCompletion?("jwt-stale") + + allowed = true + handler.send(deviceToken: "tok", appId: "app") + pendingCompletion?("jwt-fresh") + + #expect(await waitFor { self.delivered(storage) }) + #expect(mints == 2) + let post = try #require(server.pushSubscriptionRequests.last) + #expect(try #require(server.parseRequest(post))["identity_token"] as? String == "jwt-fresh") + } + @Test("500 then 200: provider minted once, both attempts carry the same token (vector 12)") func retryReusesCachedIdentityToken() async throws { server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 500 : 200 } From a4988dbab4f7ef05f5be0c6d964c3c67aa25382f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 13:38:54 +0300 Subject: [PATCH 04/12] test: relax vector-11 mint-count assertion racing reset legs --- PostHogTests/PostHogPushNotificationTest.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 1022a0784..d799891b3 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -367,8 +367,10 @@ && self.server.pushSubscriptionRequests.contains { $0.httpMethod == "POST" } }) - // The DELETE reuses user-B's token cached at identify time; only the anon re-POST - // mints. One provider invocation per distinct id across the scenario. + // Each leg carries a token minted for the id it sends. The exact invocation count is + // not asserted: reset()'s context-change resend races the explicit DELETE/re-POST + // legs, so the cache hit for the DELETE leg is timing-dependent (unlike Android, + // where the single executor serializes both legs). let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) let delBody = try #require(server.parseRequest(del)) #expect(delBody["distinct_id"] as? String == "user-B") @@ -379,7 +381,8 @@ let anonId = try #require(postBody["distinct_id"] as? String) #expect(anonId != "user-B") #expect(postBody["identity_token"] as? String == "jwt-\(anonId)") - #expect(recorder.distinctIds == ["user-A", "user-B", anonId]) + #expect(recorder.distinctIds.starts(with: ["user-A", "user-B"])) + #expect(recorder.distinctIds.contains(anonId)) } #endif From d598fd05791946baec8b940a1c788374a2fa71b5 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 28 Jul 2026 13:52:09 +0300 Subject: [PATCH 05/12] fix: harden async push identity token mint against races, hangs, and leaks --- PostHog/PostHogConfig.swift | 4 +++ .../PostHogPushSubscriptionHandler.swift | 31 ++++++++++++++++--- .../PostHogPushNotificationTest.swift | 18 +++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 694baa9b5..e10e28288 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -197,6 +197,10 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// the next launch, and once after a 401 rejection. An uncalled `completion` strands that request /// until the next launch. /// + /// The hook is invoked synchronously on the SDK's push-registration work (only `completion` may be + /// called from any thread), so return quickly: mint the token asynchronously and call `completion` + /// when it finishes. Blocking here stalls the push retry/offline-resume flow. + /// /// Default: `nil` (requests carry no identity token). @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 30fde2aae..e1f9d8b88 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -29,6 +29,12 @@ final class PostHogPushSubscriptionHandler { private static let firstRetryDelay: TimeInterval = 5 private static let maxRetryDelay: TimeInterval = 30 + /// Watchdog window for `pushIdentityProvider`: if the host never calls completion within this, fall + /// back to a token-less send so a misbehaving provider can't wedge sending for the whole process. + /// A slow legitimate mint on a bad network is cut off and retried token-less (the 401 refresh + /// re-mints). Test seam: shrunk in tests so the fallback fires quickly. Keep in parity with Android. + var identityTokenMintTimeout: TimeInterval = 10 + private let api: PostHogApi private let storage: PostHogStorage private let config: PostHogConfig @@ -302,7 +308,7 @@ final class PostHogPushSubscriptionHandler { /// provider completions are ignored (an uncalled one strands the request — see the config doc). private func resolveIdentityToken(distinctId: String, appId: String, completion: @escaping (String?) -> Void) { guard let provider = config.pushIdentityProvider else { - hedgeLog("Push subscription request sent without identity token (no pushIdentityProvider).") + hedgeLog("No identity token attached to push request (no pushIdentityProvider).") return completion(nil) } @@ -313,11 +319,28 @@ final class PostHogPushSubscriptionHandler { return cache.token } if let cached { - hedgeLog("Push subscription request sent with cached identity token.") + hedgeLog("Attaching cached identity token to push request.") return completion(cached) } 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 is a no-op via `completed`. + let timeout = identityTokenMintTimeout + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in + guard let self else { return } + let isFirst = self.stateLock.withLock { () -> Bool in + if completed { + return false + } + completed = true + return true + } + guard isFirst else { return } + hedgeLog("pushIdentityProvider did not complete within \(timeout)s; sending without identity token.") + completion(nil) + } provider(distinctId, appId) { [weak self] token in guard let self else { return } // A mint can complete after opt-out cleared the cache; caching it would resurrect a @@ -335,8 +358,8 @@ final class PostHogPushSubscriptionHandler { } guard isFirst else { return } hedgeLog(token != nil - ? "Push subscription request sent with freshly minted identity token." - : "Push subscription request sent without identity token (provider completed nil).") + ? "Attaching freshly minted identity token to push request." + : "No identity token attached to push request (provider completed nil).") completion(token) } } diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index d799891b3..c998c8dcc 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -601,6 +601,24 @@ #expect(!body.keys.contains("identity_token")) } + @Test("a provider that never completes falls back to a token-less send instead of wedging") + func neverCompletingProviderFallsBackTokenLess() async throws { + let (handler, storage, config) = makeHandler() + handler.identityTokenMintTimeout = 0.05 + config.pushIdentityProvider = { _, _, _ in } // never calls completion + + handler.send(deviceToken: "tok-1", appId: "app") + #expect(await waitFor { self.delivered(storage) }) + let firstPost = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) + #expect(!(try #require(server.parseRequest(firstPost))).keys.contains("identity_token")) + + // isSending was released by the fallback, so a later registration is not wedged. + handler.send(deviceToken: "tok-2", appId: "app") + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "POST" }.count == 2 }) + let secondPost = server.pushSubscriptionRequests.filter { $0.httpMethod == "POST" }[1] + #expect(try #require(server.parseRequest(secondPost))["device_token"] as? String == "tok-2") + } + @Test("only the first provider completion is honored") func onlyFirstProviderCompletionHonored() async throws { let (handler, storage, config) = makeHandler() From 1e9522a46ca77537439c7052c220dec30db24cd9 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 28 Jul 2026 15:38:14 +0300 Subject: [PATCH 06/12] fix: close push identity opt-out races on cache, resend, and auth retry --- .../PostHogPushSubscriptionHandler.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index e1f9d8b88..7cd503727 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -222,9 +222,13 @@ final class PostHogPushSubscriptionHandler { unregister(distinctId: distinctIdProvider(), deviceToken: record.deviceToken, appId: record.appId) } - /// Opt-out drops the cached identity credential so a later opt-in re-mints it. + /// Opt-out drops the cached identity credential so a later opt-in re-mints it, and clears the + /// per-cycle 401 retry flag so a consumed retry doesn't stay stuck and suppress the next one. func onOptOut() { - stateLock.withLock { cachedIdentityToken = nil } + stateLock.withLock { + cachedIdentityToken = nil + didAuthRetry = false + } } // MARK: - Private @@ -292,7 +296,13 @@ final class PostHogPushSubscriptionHandler { guard let self else { return } // Opt-out may land during a slow mint; re-check before sending. guard isAllowedProvider() else { - stateLock.withLock { self.isSending = false } + // Also drop any registration that folded into `pendingResend` during the mint. We're + // opted out, so it must not fire, and leaving it set would make the next send's + // `handleResult` service a stale resend. A later opt-in re-registers normally. + stateLock.withLock { + self.isSending = false + self.pendingResend = false + } return } api.pushSubscription( @@ -343,15 +353,16 @@ final class PostHogPushSubscriptionHandler { } provider(distinctId, appId) { [weak self] token in guard let self else { return } - // A mint can complete after opt-out cleared the cache; caching it would resurrect a - // stale credential on a later opt-in. The 401 refresh covers the residual race window. - let allowed = isAllowedProvider() let isFirst = self.stateLock.withLock { () -> Bool in if completed { return false } completed = true - if let token, allowed { + // Sample opt-out under the same lock onOptOut() clears the cache with, so an opt-out + // can't land between the check and the write and leave a stale token cached. If opt-out + // wins, isAllowedProvider() is false here and nothing is cached; if we win, onOptOut() + // clears our write right after we release the lock. + if let token, self.isAllowedProvider() { self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) } return true From 54070272d9e6832142bb30a8251cdfeda6930cb4 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 09:18:33 +0300 Subject: [PATCH 07/12] docs: correct pushIdentityProvider mint-timeout fallback docs --- PostHog/PostHogConfig.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index e10e28288..5075c3ec7 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -194,8 +194,10 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// `aud` = "posthog:push_identity", and an `exp`. /// /// The token is cached in memory per `(distinctId, appId)` and re-requested on identity change, on - /// the next launch, and once after a 401 rejection. An uncalled `completion` strands that request - /// until the next launch. + /// the next launch, and once after a 401 rejection. If `completion` isn't called within ~10s the + /// SDK stops waiting and sends the request without an identity token; where your project requires + /// identity verification the backend will likely reject it, so make sure your provider always + /// completes. /// /// The hook is invoked synchronously on the SDK's push-registration work (only `completion` may be /// called from any thread), so return quickly: mint the token asynchronously and call `completion` From 7128ba629d5d94346f7fe05e980c0ec691de9690 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 09:18:49 +0300 Subject: [PATCH 08/12] fix: preserve 401 retry cap when a resend coalesces mid-flight --- .../PostHogPushSubscriptionHandler.swift | 23 +++++++++++--- .../PostHogPushNotificationTest.swift | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 7cd503727..86295e18e 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -315,7 +315,8 @@ final class PostHogPushSubscriptionHandler { /// Resolves the identity token for `distinctId`/`appId`, preferring an exact cache hit. The /// completion may run synchronously or from whatever thread the provider completes on; extra - /// provider completions are ignored (an uncalled one strands the request — see the config doc). + /// provider completions are ignored (an uncalled one falls back to a token-less send after the + /// watchdog timeout — see the config doc). private func resolveIdentityToken(distinctId: String, appId: String, completion: @escaping (String?) -> Void) { guard let provider = config.pushIdentityProvider else { hedgeLog("No identity token attached to push request (no pushIdentityProvider).") @@ -392,10 +393,22 @@ final class PostHogPushSubscriptionHandler { } // A newer registration or identity change arrived while this send was in flight. Service it with - // fresh retry state so the latest token isn't stranded behind this send's backoff or halt. - if hadPendingResend, let record = loadRecord() { - resetRetryState() - attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) + // fresh retry state so the latest token isn't stranded behind this send's backoff or halt — + // unless a retry (e.g. the 401 fresh-token retry `handleFailure` just started) is already in + // flight for this cycle: reset would clear that retry's per-cycle `didAuthRetry` cap out from + // under it, so fold into it instead and let its completion service the latest record here. + if hadPendingResend { + let deferredToInFlight = stateLock.withLock { () -> Bool in + if isSending { + pendingResend = true + return true + } + return false + } + if !deferredToInFlight, let record = loadRecord() { + resetRetryState() + attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) + } } } diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index c998c8dcc..ad592d5a5 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -730,6 +730,37 @@ #expect(!delivered(storage)) } + @Test("a resend coalesced during a 401 auth-retry doesn't reopen the one-retry cap (no mint loop)") + func coalescedResendDoesNotBreakAuthRetryCap() async throws { + // Every request 401s. Hold the first mint open so a second registration coalesces into + // pendingResend while the original send is in flight; releasing it drives the 401 cascade. + // Under the bug, servicing the coalesced resend reset didAuthRetry out from under the + // in-flight auth-retry, granting an unbounded chain of fresh-token retries that never halts. + server.pushSubscriptionStatusCode = 401 + let recorder = MintRecorder() + var firstCompletion: ((String?) -> Void)? + let (handler, _, config) = makeHandler() + config.pushIdentityProvider = { distinctId, appId, completion in + let n = recorder.record(distinctId, appId) + if n == 1 { + firstCompletion = completion + } else { + DispatchQueue.global().async { completion("jwt-mint-\(n)") } + } + } + + handler.send(deviceToken: "tok-1", appId: "app") // isSending claimed, mint #1 held open + handler.send(deviceToken: "tok-2", appId: "app") // coalesces into pendingResend + DispatchQueue.global().async { firstCompletion?("jwt-mint-1") } + + // Fix: R1(tok-1) + its one auth-retry, then the resend cycle R3(tok-2) + its one + // auth-retry — four requests, then terminal. The bug never halts and requests run away. + #expect(await waitFor { handler.isHaltedForTesting }) + try await Task.sleep(nanoseconds: 300_000_000) + #expect(server.pushSubscriptionRequests.count == 4) + #expect(recorder.count <= 4) + } + @Test("401 with no provider: terminal after one request, record kept (vector 14)") func unauthorizedWithoutProviderIsTerminal() async throws { server.pushSubscriptionStatusCode = 401 From 9582ccf2e3f736f78d36a6b48ef508de6f3e5ea2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 09:35:35 +0300 Subject: [PATCH 09/12] fix: run pushIdentityProvider off the caller thread (parity with Android) --- PostHog/PostHogConfig.swift | 6 +-- .../PostHogPushSubscriptionHandler.swift | 48 +++++++++++-------- .../PostHogPushNotificationTest.swift | 34 ++++++++----- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 5075c3ec7..1e44de4cf 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -199,9 +199,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// identity verification the backend will likely reject it, so make sure your provider always /// completes. /// - /// The hook is invoked synchronously on the SDK's push-registration work (only `completion` may be - /// called from any thread), so return quickly: mint the token asynchronously and call `completion` - /// when it finishes. Blocking here stalls the push retry/offline-resume flow. + /// The hook is invoked on a background queue that serializes push-registration work — never on the + /// calling thread — and `completion` may be called from any thread. Still return promptly: blocking + /// 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)? diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 86295e18e..0977bf449 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -68,6 +68,12 @@ final class PostHogPushSubscriptionHandler { /// token. `PostHogStorage` does no locking of its own. private let recordLock = NSLock() + /// Runs `config.pushIdentityProvider` off the caller's thread (which is the host's main thread on + /// the first registration, via `didRegisterForRemoteNotificationsWithDeviceToken:`). A provider + /// that blocks can only stall this serial queue — and the mint watchdog still fires on a separate + /// queue — never the host's UI. Matches Android, where minting runs on the SDK executor. + private let mintQueue = DispatchQueue(label: "com.posthog.push.identity-mint") + private var contextChangedToken: RegistrationToken? init( @@ -352,27 +358,31 @@ final class PostHogPushSubscriptionHandler { hedgeLog("pushIdentityProvider did not complete within \(timeout)s; sending without identity token.") completion(nil) } - provider(distinctId, appId) { [weak self] token in - guard let self else { return } - let isFirst = self.stateLock.withLock { () -> Bool in - if completed { - return false - } - completed = true - // Sample opt-out under the same lock onOptOut() clears the cache with, so an opt-out - // can't land between the check and the write and leave a stale token cached. If opt-out - // wins, isAllowedProvider() is false here and nothing is cached; if we win, onOptOut() - // clears our write right after we release the lock. - if let token, self.isAllowedProvider() { - self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) + // Hop off the caller's (often main) thread: a blocking provider stalls only `mintQueue`, and + // the watchdog above still fires from `DispatchQueue.global()` to unwedge the send. + mintQueue.async { + provider(distinctId, appId) { [weak self] token in + guard let self else { return } + let isFirst = self.stateLock.withLock { () -> Bool in + if completed { + return false + } + completed = true + // Sample opt-out under the same lock onOptOut() clears the cache with, so an opt-out + // can't land between the check and the write and leave a stale token cached. If opt-out + // wins, isAllowedProvider() is false here and nothing is cached; if we win, onOptOut() + // clears our write right after we release the lock. + if let token, self.isAllowedProvider() { + self.cachedIdentityToken = CachedIdentityToken(token: token, distinctId: distinctId, appId: appId) + } + return true } - return true + guard isFirst else { return } + hedgeLog(token != nil + ? "Attaching freshly minted identity token to push request." + : "No identity token attached to push request (provider completed nil).") + completion(token) } - guard isFirst else { return } - hedgeLog(token != nil - ? "Attaching freshly minted identity token to push request." - : "No identity token attached to push request (provider completed nil).") - completion(token) } } diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index ad592d5a5..2b08bd3a9 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -637,26 +637,32 @@ @Test("a mint completing after opt-out is not cached; opt-in re-mints") func lateMintAfterOptOutNotCached() async throws { + // The provider is invoked off the caller's thread, so guard the shared state with a lock + // and wait for each mint to land before driving its completion. + let lock = NSLock() var allowed = true var pendingCompletion: ((String?) -> Void)? var mints = 0 - let (handler, storage, config) = makeHandler(isAllowedProvider: { allowed }) + let (handler, storage, config) = makeHandler(isAllowedProvider: { lock.withLock { allowed } }) config.pushIdentityProvider = { _, _, completion in - mints += 1 - pendingCompletion = completion + lock.withLock { mints += 1 + pendingCompletion = completion + } } handler.send(deviceToken: "tok", appId: "app") - allowed = false + #expect(await waitFor { lock.withLock { mints } == 1 }) + lock.withLock { allowed = false } handler.onOptOut() - pendingCompletion?("jwt-stale") + lock.withLock { pendingCompletion }?("jwt-stale") - allowed = true + lock.withLock { allowed = true } handler.send(deviceToken: "tok", appId: "app") - pendingCompletion?("jwt-fresh") + #expect(await waitFor { lock.withLock { mints } == 2 }) + lock.withLock { pendingCompletion }?("jwt-fresh") #expect(await waitFor { self.delivered(storage) }) - #expect(mints == 2) + #expect(lock.withLock { mints } == 2) let post = try #require(server.pushSubscriptionRequests.last) #expect(try #require(server.parseRequest(post))["identity_token"] as? String == "jwt-fresh") } @@ -738,20 +744,24 @@ // in-flight auth-retry, granting an unbounded chain of fresh-token retries that never halts. server.pushSubscriptionStatusCode = 401 let recorder = MintRecorder() + let lock = NSLock() var firstCompletion: ((String?) -> Void)? let (handler, _, config) = makeHandler() config.pushIdentityProvider = { distinctId, appId, completion in let n = recorder.record(distinctId, appId) if n == 1 { - firstCompletion = completion + lock.withLock { firstCompletion = completion } } else { DispatchQueue.global().async { completion("jwt-mint-\(n)") } } } - handler.send(deviceToken: "tok-1", appId: "app") // isSending claimed, mint #1 held open - handler.send(deviceToken: "tok-2", appId: "app") // coalesces into pendingResend - DispatchQueue.global().async { firstCompletion?("jwt-mint-1") } + // `isSending` is claimed synchronously in send #1, so send #2 coalesces into pendingResend + // before mint #1 (which runs off-thread) completes. Wait for that mint, then release it. + handler.send(deviceToken: "tok-1", appId: "app") + handler.send(deviceToken: "tok-2", appId: "app") + #expect(await waitFor { recorder.count >= 1 }) + lock.withLock { firstCompletion }?("jwt-mint-1") // Fix: R1(tok-1) + its one auth-retry, then the resend cycle R3(tok-2) + its one // auth-retry — four requests, then terminal. The bug never halts and requests run away. From f1bd05e1aff781afd945aa053a19ac7399827b49 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 13:50:45 +0300 Subject: [PATCH 10/12] fix: make push unregister durable across offline, 401, and re-registration --- PostHog/PostHogSDK.swift | 7 +- PostHog/PostHogStorage.swift | 1 + .../PostHogPushSubscriptionHandler.swift | 155 +++++++++++++++--- .../PostHogPushNotificationTest.swift | 91 +++++++++- 4 files changed, 221 insertions(+), 33 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index a3dd57318..279fe53c4 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2981,9 +2981,10 @@ let maxRetryDelay = 30.0 /// Unregisters this device's push token from PostHog so Workflows stop targeting it — for example /// from your logout flow. /// - /// Sends a best-effort `DELETE /api/push_subscriptions/` for the current distinct id (the backend - /// unsets the subscription property) and forgets the locally stored token. Unlike registration this - /// is not retried. Call it directly if you manage push subscriptions yourself. On `reset()` the SDK + /// Sends a `DELETE /api/push_subscriptions/` for the current distinct id (the backend unsets the + /// subscription property) and forgets the locally stored token. The delete intent is durable: an + /// offline or failed attempt is retried on `flush()`/next launch until it succeeds or hits a + /// terminal 4xx. Call it directly if you manage push subscriptions yourself. On `reset()` the SDK /// already moves any registered token to the new anonymous identity (unregister then re-register), /// independently of `capturePushNotificationSubscriptions` — that flag only gates automatic token /// subscription at startup. diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index 3fa4618be..219c88961 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -255,6 +255,7 @@ class PostHogStorage { case capturePerformance = "posthog.capturePerformance" case deviceId = "posthog.deviceId" case pushSubscription = "posthog.pushSubscription" + case pushPendingUnregister = "posthog.pushPendingUnregister" } // The location for storing data that we always want to keep diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 0977bf449..a0ea96026 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -12,6 +12,7 @@ final class PostHogPushSubscriptionHandler { static let deviceToken = "deviceToken" static let appId = "appId" static let deliveredForDistinctId = "deliveredForDistinctId" + static let distinctId = "distinctId" } private struct PendingRecord { @@ -20,6 +21,20 @@ final class PostHogPushSubscriptionHandler { let deliveredForDistinctId: String? } + /// A durable "delete this subscription" intent for the identity it names, persisted so an offline or + /// failed unregister (logout/reset) is retried on `flush()`/next launch instead of silently leaving + /// the device associated with the logged-out user. Separate key from `PendingRecord` so it coexists + /// with a fresh registration the reset path writes under the new anonymous id. + /// + /// Single-slot, last-write-wins: only one identity's unregister can be pending at a time. Two + /// overlapping logouts (rapid double-reset) drop the older intent — acceptable given one device token + /// and how rare that is; a per-identity queue would be the fix if it ever matters. + private struct PendingUnregister: Equatable { + let distinctId: String + let deviceToken: String + let appId: String + } + private struct CachedIdentityToken { let token: String let distinctId: String @@ -140,6 +155,13 @@ final class PostHogPushSubscriptionHandler { /// Retries a persisted, not-yet-delivered subscription if the backoff window has elapsed. /// Called from `PostHogSDK.flush()`. func retryIfNeeded() { + // Drain any pending unregister first (offline logout, transport failure) — independent of the + // send record, which is usually absent after a logout. On this path the in-memory identity-token + // cache is empty, so the retry re-mints a fresh token and sidesteps the expired-token 401. + if let pending = loadPendingUnregister() { + attemptUnregister(pending) + } + guard let record = loadRecord() else { return } let distinctId = distinctIdProvider() @@ -158,9 +180,10 @@ final class PostHogPushSubscriptionHandler { attemptIfAllowed(deviceToken: record.deviceToken, appId: record.appId) } - /// Best-effort unregister: a single `DELETE /api/push_subscriptions` for `distinctId`. Unlike - /// `send`, there is no retry, backoff, or persistence — a failure is logged and dropped (the - /// backend also unsets a dead token on the next send, and the durable path is the re-register). + /// Unregister: `DELETE /api/push_subscriptions` for `distinctId`. The intent is persisted first, so an + /// offline or failed attempt is retried on `flush()`/next launch (see `PendingUnregister`) rather than + /// dropped — otherwise a logout while offline, or with an expired identity token, would leave the + /// device associated with the logged-out user on the backend. Cleared on a 2xx or a terminal 4xx. func unregister(distinctId: String, deviceToken: String, appId: String) { guard isAllowedProvider() else { hedgeLog("Push unregister skipped: SDK is disabled or opted out.") @@ -170,22 +193,59 @@ final class PostHogPushSubscriptionHandler { hedgeLog("Push unregister skipped: missing distinct id, token, or app id.") return } - // Same one-verification-state as registration: the DELETE resolves a token for the distinct id - // it carries (the old identity on the reset() path). Still best-effort — no 401 refresh here. - resolveIdentityToken(distinctId: distinctId, appId: appId) { [weak self] identityToken in + let pending = PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + writePendingUnregister(pending) + attemptUnregister(pending) + } + + /// Attempts the persisted DELETE. Offline defers without clearing the intent (retried later). On a 401 + /// with a provider, re-mints a fresh identity token once and retries — the same-process logout path may + /// hold an expired cached token (the provider mints a short TTL). `isRetry` caps that to one re-mint. + private func attemptUnregister(_ pending: PendingUnregister, isRetry: Bool = false) { + guard isAllowedProvider() else { return } + guard isConnectedProvider() else { + hedgeLog("Push unregister deferred: no network connection. Will retry on flush/next launch.") + return + } + // Same one-verification-state as registration: the DELETE resolves a token for the distinct id it + // carries (the old identity on the reset() path). + resolveIdentityToken(distinctId: pending.distinctId, appId: pending.appId) { [weak self] identityToken in guard let self, isAllowedProvider() else { return } api.deletePushSubscription( - distinctId: distinctId, deviceToken: deviceToken, appId: appId, identityToken: identityToken - ) { info in - if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { - hedgeLog("Push subscription unregistered successfully.") - } else { - hedgeLog("Push unregister failed (status \(info.statusCode.map(String.init) ?? "none")); ignoring (best-effort).") - } + distinctId: pending.distinctId, deviceToken: pending.deviceToken, appId: pending.appId, + identityToken: identityToken + ) { [weak self] info in + self?.handleUnregisterResult(info, pending: pending, isRetry: isRetry) } } } + private func handleUnregisterResult(_ info: PostHogUploadInfo, pending: PendingUnregister, isRetry: Bool) { + if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { + clearPendingUnregister(matching: pending) + hedgeLog("Push subscription unregistered successfully.") + return + } + + // 401: identity verification failed. Re-mint once and retry, mirroring the send-path 401 refresh. + if info.statusCode == 401, config.pushIdentityProvider != nil, !isRetry { + stateLock.withLock { cachedIdentityToken = nil } + hedgeLog("Push unregister rejected (401). Retrying once with a fresh identity token.") + attemptUnregister(pending, isRetry: true) + return + } + + // Retryable (transport error, 429, 5xx): keep the intent for flush/next launch. + if isRetryable(info) { + hedgeLog("Push unregister failed (status \(statusString(info))); will retry on flush/next launch.") + return + } + + // Terminal (post-retry 401, 404 already gone, other 4xx): drop the intent, best-effort ceiling. + clearPendingUnregister(matching: pending) + hedgeLog("Push unregister failed (status \(statusString(info))); dropping (best-effort).") + } + /// Snapshot of the stored token/appId, read *before* `reset()` clears storage so the old /// identity's subscription can be DELETEd and then re-registered under the new anonymous id. func recordForReset() -> (deviceToken: String, appId: String)? { @@ -443,22 +503,14 @@ final class PostHogPushSubscriptionHandler { } } - // Retryable: transport error (no status), 429, or 5xx. Everything else (400/401) is terminal. - let retryable: Bool - if let statusCode = info.statusCode { - retryable = statusCode == 429 || (500 ... 599 ~= statusCode) - } else { - retryable = true - } - - guard retryable else { + guard isRetryable(info) else { stateLock.withLock { halted = true } if info.statusCode == 401, config.pushIdentityProvider == nil { hedgeLog( "Push subscription rejected (401): identity verification may be required — set config.pushIdentityProvider. Keeping record for next launch." ) } else { - hedgeLog("Push subscription rejected (status \(info.statusCode.map(String.init) ?? "none")). Keeping record for next launch.") + hedgeLog("Push subscription rejected (status \(statusString(info))). Keeping record for next launch.") } return } @@ -491,6 +543,13 @@ final class PostHogPushSubscriptionHandler { } writeRecord(deviceToken: deviceToken, appId: appId, deliveredForDistinctId: distinctId) + + // A fresh registration delivered to this identity supersedes any queued logout-DELETE for + // it (log out of A, then back into A): otherwise the next retryIfNeeded() drain would + // unregister the subscription we just re-registered. + clearPendingUnregisterLocked( + matching: PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + ) } } @@ -525,6 +584,52 @@ final class PostHogPushSubscriptionHandler { return PendingRecord(deviceToken: deviceToken, appId: appId, deliveredForDistinctId: data[Key.deliveredForDistinctId]) } + private func writePendingUnregister(_ pending: PendingUnregister) { + recordLock.withLock { + storage.setDictionary(forKey: .pushPendingUnregister, contents: [ + Key.distinctId: pending.distinctId, + Key.deviceToken: pending.deviceToken, + Key.appId: pending.appId, + ]) + } + } + + private func loadPendingUnregister() -> PendingUnregister? { + recordLock.withLock { loadPendingUnregisterLocked() } + } + + private func loadPendingUnregisterLocked() -> PendingUnregister? { + guard let data = storage.getDictionary(forKey: .pushPendingUnregister) as? [String: String], + let distinctId = data[Key.distinctId], + let deviceToken = data[Key.deviceToken], + let appId = data[Key.appId] + else { + return nil + } + return PendingUnregister(distinctId: distinctId, deviceToken: deviceToken, appId: appId) + } + + /// Clears the intent only if it's still the one that just resolved — a newer unregister (a second + /// logout while this DELETE was in flight) may have overwritten the slot, and its intent must not be + /// dropped by this stale completion. + private func clearPendingUnregister(matching pending: PendingUnregister) { + recordLock.withLock { clearPendingUnregisterLocked(matching: pending) } + } + + private func clearPendingUnregisterLocked(matching pending: PendingUnregister) { + guard loadPendingUnregisterLocked() == pending else { return } + storage.remove(key: .pushPendingUnregister) + } + + /// Transport error (no status), 429, or 5xx is retryable; everything else (4xx) is terminal. + private func isRetryable(_ info: PostHogUploadInfo) -> Bool { + info.statusCode.map { $0 == 429 || (500 ... 599 ~= $0) } ?? true + } + + private func statusString(_ info: PostHogUploadInfo) -> String { + info.statusCode.map(String.init) ?? "none" + } + /// Exponential backoff: `min(5 * 2^(attempt-1), 30)` → 5, 10, 20, 30, 30, … func retryDelay(forAttempt attempt: Int) -> TimeInterval { min(Self.firstRetryDelay * pow(2, Double(attempt - 1)), Self.maxRetryDelay) @@ -545,5 +650,9 @@ final class PostHogPushSubscriptionHandler { func clearBackoffForTesting() { stateLock.withLock { pausedUntil = nil } } + + var hasPendingUnregisterForTesting: Bool { + loadPendingUnregister() != nil + } } #endif diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 2b08bd3a9..5aa7309be 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -84,6 +84,9 @@ let storage = PostHogStorage(config) if resetStorage { storage.reset() + // reset() deliberately keeps .pushPendingUnregister (a logout DELETE must outlive reset); + // clear it here so a durable intent from a prior test can't leak into this handler. + storage.remove(key: .pushPendingUnregister) } let handler = PostHogPushSubscriptionHandler( api, @@ -243,9 +246,10 @@ // MARK: - Unregister (decision 6) - @Test("unregister sends exactly one DELETE with the 5-field body and never retries (vector 7)") + @Test("unregister sends exactly one DELETE with the 5-field body per attempt (vector 7)") func unregisterFiresOneDeleteNoRetry() async throws { - // Even a 500 must not be retried — unregister is best-effort, single-shot. + // A 500 fires one DELETE per attempt (no immediate retry); the intent is kept and retried + // only on flush()/next launch, so no second DELETE appears within this call. server.pushSubscriptionStatusCode = 500 let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }) @@ -784,8 +788,29 @@ #expect(!delivered(storage)) } - @Test("unregister DELETE gets no 401 fresh-token retry (best-effort, single-shot)") - func unregisterNoAuthRetry() async throws { + @Test("unregister DELETE gets one 401 fresh-token retry then succeeds (mirrors register vector 13)") + func unregisterAuthRetryWithFreshTokenSucceeds() async throws { + // A same-process logout can carry an expired cached token; a 401 must re-mint and retry once. + server.pushSubscriptionStatusHandler = { requestNumber in requestNumber == 1 ? 401 : 200 } + let recorder = MintRecorder() + let (handler, _, config) = makeHandler(distinctIdProvider: { "user-1" }) + config.pushIdentityProvider = { distinctId, appId, completion in + completion("jwt-mint-\(recorder.record(distinctId, appId))") + } + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2 }) + #expect(recorder.count == 2) // cache cleared on the 401, so the retry re-mints + let deletes = server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" } + #expect(try #require(server.parseRequest(deletes[0]))["identity_token"] as? String == "jwt-mint-1") + #expect(try #require(server.parseRequest(deletes[1]))["identity_token"] as? String == "jwt-mint-2") + // Success on the retry clears the durable intent. + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + } + + @Test("unregister twice-401 is terminal: exactly one fresh-token retry, then intent dropped") + func unregisterAuthRetryTerminalDropsIntent() async throws { server.pushSubscriptionStatusCode = 401 let recorder = MintRecorder() let (handler, _, config) = makeHandler(distinctIdProvider: { "user-1" }) @@ -795,10 +820,62 @@ handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") - #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + #expect(await waitFor { self.server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2 }) try await Task.sleep(nanoseconds: 300_000_000) - #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 1) - #expect(recorder.count == 1) + #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == 2) + #expect(recorder.count == 2) + // A terminal 401 is a best-effort ceiling — the intent is dropped, not retried on flush. + #expect(!handler.hasPendingUnregisterForTesting) + } + + @Test("unregister offline persists the intent and drains it on retryIfNeeded") + func unregisterOfflinePersistsAndDrains() async throws { + var connected = false + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }, isConnectedProvider: { connected }) + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.isEmpty) // no attempt while offline + #expect(handler.hasPendingUnregisterForTesting) // but the delete intent is durable + + connected = true + handler.retryIfNeeded() + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) // cleared on 2xx + } + + @Test("unregister drops the pending intent on a terminal 4xx") + func unregisterTerminalDropsIntent() async throws { + server.pushSubscriptionStatusCode = 400 + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-1" }) + + handler.unregister(distinctId: "user-1", deviceToken: "tok", appId: "app") + + #expect(await waitFor { self.server.pushSubscriptionRequests.contains { $0.httpMethod == "DELETE" } }) + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + } + + @Test("re-registering the same identity cancels a queued unregister for it") + func reRegisterCancelsPendingUnregister() async throws { + var connected = false + let (handler, _, _) = makeHandler(distinctIdProvider: { "user-A" }, isConnectedProvider: { connected }) + + // Offline logout queues a durable unregister for user-A. + handler.unregister(distinctId: "user-A", deviceToken: "tok", appId: "app") + try await Task.sleep(nanoseconds: 150_000_000) + #expect(handler.hasPendingUnregisterForTesting) + + // Re-login as user-A: a delivered registration must supersede the stale logout-DELETE. + connected = true + handler.send(deviceToken: "tok", appId: "app") + #expect(await waitFor { !handler.hasPendingUnregisterForTesting }) + + // A later flush must not delete the freshly re-registered subscription. + let deletesBefore = server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count + handler.retryIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + #expect(server.pushSubscriptionRequests.filter { $0.httpMethod == "DELETE" }.count == deletesBefore) } // MARK: - Offline From fecf873557a33cc17b713f8715279c077f527ea1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 15:33:53 +0300 Subject: [PATCH 11/12] fix: guard mock server push request array against concurrent append race --- PostHogTests/TestUtils/MockPostHogServer.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index b0e169356..ad9284513 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -24,7 +24,16 @@ class MockPostHogServer { var logsExpectationCount: Int? var flagsExpectationCount: Int? var flagsRequests = [URLRequest]() - var pushSubscriptionRequests = [URLRequest]() + /// Appended from OHHTTPStubs' network queue while tests read/reset it from their own thread. The + /// push suite drives heavy cross-thread resend churn (mint queue + watchdog + context-change + /// resend), so unlike the other bare request arrays this one races — a lock keeps the concurrent + /// append from corrupting the array (segfault). Access only via `pushSubscriptionRequests`. + private let pushSubscriptionRequestsLock = NSLock() + private var _pushSubscriptionRequests = [URLRequest]() + var pushSubscriptionRequests: [URLRequest] { + get { pushSubscriptionRequestsLock.withLock { _pushSubscriptionRequests } } + set { pushSubscriptionRequestsLock.withLock { _pushSubscriptionRequests = newValue } } + } /// Forces `/push_subscriptions` to reply 500 (independent of the global `return500`). var returnPushSubscription500 = false /// When set, `/push_subscriptions` replies with this exact status code (takes precedence over the @@ -365,11 +374,14 @@ class MockPostHogServer { }) stubDescriptors.append(stub(condition: pathEndsWith("/push_subscriptions")) { request in - self.pushSubscriptionRequests.append(request) + let requestCount = self.pushSubscriptionRequestsLock.withLock { () -> Int in + self._pushSubscriptionRequests.append(request) + return self._pushSubscriptionRequests.count + } let status: Int if let handler = self.pushSubscriptionStatusHandler { - status = handler(self.pushSubscriptionRequests.count) + status = handler(requestCount) } else if let code = self.pushSubscriptionStatusCode { status = code } else if self.return500 || self.returnPushSubscription500 { From 96dad3c502d144c9ab06cfdb364348a6e084397c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 17:20:50 +0300 Subject: [PATCH 12/12] fix: correct vector 11 push test to use a valid single-identity reset flow --- .../PostHogPushNotificationTest.swift | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 5aa7309be..e28398bff 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -356,12 +356,6 @@ #expect(await waitFor { (sut.storage?.getDictionary(forKey: .pushSubscription) as? [String: String])?["deliveredForDistinctId"] == "user-A" }) - - // identify() re-registers under the new id (decision 5), minting user-B's token. - sut.identify("user-B") - #expect(await waitFor { - (sut.storage?.getDictionary(forKey: .pushSubscription) as? [String: String])?["deliveredForDistinctId"] == "user-B" - }) server.pushSubscriptionRequests = [] sut.reset() @@ -371,21 +365,22 @@ && self.server.pushSubscriptionRequests.contains { $0.httpMethod == "POST" } }) - // Each leg carries a token minted for the id it sends. The exact invocation count is - // not asserted: reset()'s context-change resend races the explicit DELETE/re-POST - // legs, so the cache hit for the DELETE leg is timing-dependent (unlike Android, - // where the single executor serializes both legs). + // The DELETE leg reuses the token already cached for the delivered id (no re-mint); the + // re-POST leg mints a fresh token for the new anon id. The exact invocation count is not + // asserted: reset()'s context-change resend races the explicit DELETE/re-POST legs, so the + // cache hit for the DELETE leg is timing-dependent (unlike Android, where the single + // executor serializes both legs). let del = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "DELETE" }) let delBody = try #require(server.parseRequest(del)) - #expect(delBody["distinct_id"] as? String == "user-B") - #expect(delBody["identity_token"] as? String == "jwt-user-B") + #expect(delBody["distinct_id"] as? String == "user-A") + #expect(delBody["identity_token"] as? String == "jwt-user-A") let post = try #require(server.pushSubscriptionRequests.first { $0.httpMethod == "POST" }) let postBody = try #require(server.parseRequest(post)) let anonId = try #require(postBody["distinct_id"] as? String) - #expect(anonId != "user-B") + #expect(anonId != "user-A") #expect(postBody["identity_token"] as? String == "jwt-\(anonId)") - #expect(recorder.distinctIds.starts(with: ["user-A", "user-B"])) + #expect(recorder.distinctIds.starts(with: ["user-A"])) #expect(recorder.distinctIds.contains(anonId)) } #endif