You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Registering a supplementary APNs token (location / push-to-start) during activation can prematurely create the device registration without the default token #2220
This issue was created by Claude Code (an AI coding agent) while reviewing #2219. Its findings have not been validated by a human — treat them as a starting point and verify each claim before acting on it.
Top-line
Ideally, callers should not need to gate on anything. It should be possible to hand the SDK any APNs token — default, location (+[ARTPush didRegisterForLocationNotificationsWithDeviceToken:...]), or push-to-start (-[ARTPush registerPushToStartToken:], added in Add API for registering Live Activity push-to-start token #2219) — in any order and at any time relative to activate(), and have the device registration eventually converge to include all of them without any special sequencing on the caller's part. Today that is not the case (see Mechanism below).
If we keep a gating requirement, everything must be consistent about it. The de-facto contract today is "register supplementary tokens only after didActivateAblyPush," but it is undocumented and applied inconsistently across our own example apps, docs, and public-API doc-comments (see "applied inconsistently" section below). Whichever route we choose, example code, docs, and doc-comments should all agree.
Summary
Registering a supplementary APNs token — a location token via +[ARTPush didRegisterForLocationNotificationsWithDeviceToken:...], or (as of #2219) a Live Activity push-to-start token via -[ARTPush registerPushToStartToken:] — while the activation state machine is in WaitingForPushDeviceDetails causes the initial device registration (POST /push/deviceRegistrations) to be made immediately, before the default APNs token has arrived. The registration is therefore created with a recipient that carries only the supplementary token and no default token.
The default token is later synced in via a separate PATCH, so the end state is eventually consistent, but the device is momentarily registered without the APNs token it needs for ordinary push, and an extra round-trip is incurred.
This is not introduced by #2219 — it is a pre-existing sharp edge that already affects location tokens; #2219 adds a second supplementary token type (push-to-start) with the same exposure. Location and push-to-start tokens are both delivered at an arbitrary, OS-determined moment, so neither is inherently more or less likely to hit the window; in practice both example apps avoid it by gating (see below).
(All file/line references below are pinned to specific commits; see Sources consulted at the end.)
Mechanism
All APNs tokens (default, location, push-to-start) funnel through -[ARTRest setAndPersistAPNSDeviceTokenData:tokenType:], which stores the token in the LocalDevice recipient and sends a GotPushDeviceDetails event to the state machine — Source/ARTRest.m L855–L871 (ably-cocoa@0fec56c).
The handling of GotPushDeviceDetails is token-type-agnostic. In WaitingForPushDeviceDetails (Source/ARTPushActivationState.m L172–L175, ably-cocoa@0fec56c), anyGotPushDeviceDetails triggers the initial registration POST:
elseif ([event isKindOfClass:[ARTPushActivationEventGotPushDeviceDetails class]]) {
[self.machine deviceRegistration:nil]; // POST — does not check that the default token is presentreturn [ARTPushActivationStateWaitingForDeviceRegistration ...];
}
validateAndSync (the CalledActivate handler, Source/ARTPushActivationState.m L77–L100, ably-cocoa@0fec56c) only treats the default token as "the necessary push details" ([local apnsDeviceToken], which returns recipient.apnsDeviceTokens["default"]). So activate() requests the default token and parks in WaitingForPushDeviceDetails waiting for it. The state machine cannot distinguish "the default token I'm waiting on to complete activation" from "a supplementary token that just happened to arrive," so a supplementary token arriving in this window is misread as activation-completing input.
This maps onto the spec (RSH3b3, RSH3a2c/RSH3a2d, RSH3d3 — specification/specifications/features.md @ specification@6dc3ac9), which is itself type-agnostic: it has no concept of supplementary token types, so "the necessary push details" is open to the current default-token-only interpretation.
Timeline that reproduces it
push.activate() → no default token yet → registerForAPNS, state WaitingForPushDeviceDetails.
Before the OS delivers the default APNs token, a supplementary token arrives (e.g. ActivityKit yields a push-to-start token, or startMonitoringLocationPushes's completion fires) and is registered.
GotPushDeviceDetails (from the supplementary token) → POST with a recipient containing only the supplementary token.
Default token arrives later → GotPushDeviceDetails is queued (RSH4) → after GotDeviceRegistration → WaitingForNewPushDeviceDetails → PATCH adds the default token.
The supplementary tokens can arrive at an arbitrary OS-determined moment for all three delivery mechanisms (default: delegate callback; location: completion handler; push-to-start: async stream), so nothing about the delivery guarantees the default token wins the race.
Current workaround (both example apps gate on activation completing)
Both example apps avoid the window by ensuring a supplementary token can only reach the SDK once the device is already registered (state WaitingForNewPushDeviceDetails, RSH3d3 PATCH path):
Location — Examples/AblyPush/AblyPushExample/AblyHelper.swift (ably-cocoa@0fec56c) does not call startMonitoringLocationPushes until didActivateAblyPush fires (activateLocationPush() is invoked from inside didActivateAblyPush).
Push-to-start — the reference app AblyPushManager.swift (ttypic/liveactivity-example@2e6f6c3) holds the token in a pendingPushToStartToken property and only calls registerPushToStartToken(_:) from didActivateAblyPush.
This works, but it is an undocumented contract encoded only in the structure of the examples. #2219's header doc-comment ("Use this when the device has already been activated") is the first place the requirement is stated at all, and even that does not explain the consequence of ignoring it.
The requirement is applied inconsistently across our materials
The gating contract ("register supplementary tokens only after didActivateAblyPush") is neither stated as a contract anywhere nor followed uniformly. Full inventory of what each source currently does:
Example apps — both gate:
Bundled ably-cocoa example, Examples/AblyPush/AblyPushExample/AblyHelper.swift (@0fec56c), location — ✅ gates: activateLocationPush() is called from inside didActivateAblyPush.
Push-to-start reference app, AblyPushManager.swift (ttypic/liveactivity-example@2e6f6c3, linked from Add API for registering Live Activity push-to-start token #2219), push-to-start — ✅ gates: holds the token in pendingPushToStartToken and only calls registerPushToStartToken(_:) from didActivateAblyPush.
Docs — one gates, one doesn't:
Live Activities docs, src/pages/docs/push/live-activities.mdx (#3464, docs@2342df7) — ✅ gates / mentions it: instructs registering the push-to-start token only after didActivateAblyPush.
Location push docs, src/pages/docs/push/getting-started/apns.mdx Step 6 (docs@2342df7) — ❌ does not gate / silent: enableLocationPush is a standalone UI action (and is also triggered from locationManagerDidChangeAuthorization), independent of activation; didUpdateAblyPush is used only to report the token result. A user who enables location push before/during activation lands directly in the race window.
SDK public-API doc-comments (Source/include/Ably/ARTPush.h, @0fec56c) — the two supplementary token types are documented differently:
registerPushToStartToken: (new in Add API for registering Live Activity push-to-start token #2219, L95–L103) — ⚠️partial: says "Use this when the device has already been activated (i.e. -activate has completed)", stating the precondition but not the consequence of ignoring it.
didRegisterForLocationNotificationsWithDeviceToken: / didFailToRegisterForLocationNotificationsWithError: (existing, L81–L93) — ❌ silent: describe only how to obtain the token; say nothing about any activation requirement.
So even within a single header the two supplementary token types are documented inconsistently, and our example apps (which gate) contradict our location docs (which don't). If we keep the gating requirement, all of the above should say the same thing; if we remove the need for it (preferred — see Top-line), the registerPushToStartToken: "use when already activated" caveat should be dropped too.
Proposed fix
Make the gating unnecessary by not letting a supplementary-token GotPushDeviceDetails complete activation while in WaitingForPushDeviceDetails. Options:
In WaitingForPushDeviceDetails, only proceed to the registration POST once the default token is present; otherwise treat the event as a recipient update and stay in the state. (May need a spec clarification of "the necessary push details" in RSH3a2c/RSH3b3.)
Alternatively, document the contract explicitly on the public API: "register supplementary APNs tokens (location, push-to-start) only after didActivateAblyPush."
Option 1 removes the burden for both token types; option 2 at least makes the existing requirement discoverable.
Either way, the location push docs (apns.mdx Step 6) should be brought into line as a follow-up — if we go with option 1 no change is needed, and if we go with option 2 the docs should show the gating that the bundled example app already does. (Tracked here rather than as a separate docs issue.)
Notes
Behaviour is eventually consistent today, so this is a correctness/robustness and API-ergonomics issue rather than a hard failure.
* Registers a Live Activity push-to-start token (obtained from `Activity.pushToStartTokenUpdates`) with Ably, adding it to the existing device registration. Use this when the device has already been activated (i.e. `-activate` has completed). The token is stored in `ARTLocalDevice`'s push recipient and synced to Ably.
* You should implement `-[ARTPushRegistererDelegate didUpdateAblyPush:]` to handle success or failure of this operation.
*
* @param token The push-to-start token data, as delivered by `Activity.pushToStartTokenUpdates`.
ably/docs @ 2342df723a895402b17a0dbe7fea725c4cfd9224 (head of docs PR #3464; main at a27fa7991210db7f83b191861edec4400b7a56c4). apns.mdx is unchanged by #3464.
Note
This issue was created by Claude Code (an AI coding agent) while reviewing #2219. Its findings have not been validated by a human — treat them as a starting point and verify each claim before acting on it.
Top-line
Ideally, callers should not need to gate on anything. It should be possible to hand the SDK any APNs token — default, location (
+[ARTPush didRegisterForLocationNotificationsWithDeviceToken:...]), or push-to-start (-[ARTPush registerPushToStartToken:], added in Add API for registering Live Activity push-to-start token #2219) — in any order and at any time relative toactivate(), and have the device registration eventually converge to include all of them without any special sequencing on the caller's part. Today that is not the case (see Mechanism below).If we keep a gating requirement, everything must be consistent about it. The de-facto contract today is "register supplementary tokens only after
didActivateAblyPush," but it is undocumented and applied inconsistently across our own example apps, docs, and public-API doc-comments (see "applied inconsistently" section below). Whichever route we choose, example code, docs, and doc-comments should all agree.Summary
Registering a supplementary APNs token — a location token via
+[ARTPush didRegisterForLocationNotificationsWithDeviceToken:...], or (as of #2219) a Live Activity push-to-start token via-[ARTPush registerPushToStartToken:]— while the activation state machine is inWaitingForPushDeviceDetailscauses the initial device registration (POST /push/deviceRegistrations) to be made immediately, before the default APNs token has arrived. The registration is therefore created with a recipient that carries only the supplementary token and nodefaulttoken.The default token is later synced in via a separate PATCH, so the end state is eventually consistent, but the device is momentarily registered without the APNs token it needs for ordinary push, and an extra round-trip is incurred.
This is not introduced by #2219 — it is a pre-existing sharp edge that already affects location tokens; #2219 adds a second supplementary token type (push-to-start) with the same exposure. Location and push-to-start tokens are both delivered at an arbitrary, OS-determined moment, so neither is inherently more or less likely to hit the window; in practice both example apps avoid it by gating (see below).
(All file/line references below are pinned to specific commits; see Sources consulted at the end.)
Mechanism
All APNs tokens (default, location, push-to-start) funnel through
-[ARTRest setAndPersistAPNSDeviceTokenData:tokenType:], which stores the token in theLocalDevicerecipient and sends aGotPushDeviceDetailsevent to the state machine —Source/ARTRest.mL855–L871 (ably-cocoa@0fec56c).The handling of
GotPushDeviceDetailsis token-type-agnostic. InWaitingForPushDeviceDetails(Source/ARTPushActivationState.mL172–L175,ably-cocoa@0fec56c), anyGotPushDeviceDetailstriggers the initial registration POST:validateAndSync(theCalledActivatehandler,Source/ARTPushActivationState.mL77–L100,ably-cocoa@0fec56c) only treats the default token as "the necessary push details" ([local apnsDeviceToken], which returnsrecipient.apnsDeviceTokens["default"]). Soactivate()requests the default token and parks inWaitingForPushDeviceDetailswaiting for it. The state machine cannot distinguish "the default token I'm waiting on to complete activation" from "a supplementary token that just happened to arrive," so a supplementary token arriving in this window is misread as activation-completing input.This maps onto the spec (
RSH3b3,RSH3a2c/RSH3a2d,RSH3d3—specification/specifications/features.md@specification@6dc3ac9), which is itself type-agnostic: it has no concept of supplementary token types, so "the necessary push details" is open to the current default-token-only interpretation.Timeline that reproduces it
push.activate()→ no default token yet →registerForAPNS, stateWaitingForPushDeviceDetails.startMonitoringLocationPushes's completion fires) and is registered.GotPushDeviceDetails(from the supplementary token) → POST with a recipient containing only the supplementary token.GotPushDeviceDetailsis queued (RSH4) → afterGotDeviceRegistration→WaitingForNewPushDeviceDetails→ PATCH adds the default token.The supplementary tokens can arrive at an arbitrary OS-determined moment for all three delivery mechanisms (default: delegate callback; location: completion handler; push-to-start: async stream), so nothing about the delivery guarantees the default token wins the race.
Current workaround (both example apps gate on activation completing)
Both example apps avoid the window by ensuring a supplementary token can only reach the SDK once the device is already registered (state
WaitingForNewPushDeviceDetails, RSH3d3 PATCH path):Examples/AblyPush/AblyPushExample/AblyHelper.swift(ably-cocoa@0fec56c) does not callstartMonitoringLocationPushesuntildidActivateAblyPushfires (activateLocationPush()is invoked from insidedidActivateAblyPush).AblyPushManager.swift(ttypic/liveactivity-example@2e6f6c3) holds the token in apendingPushToStartTokenproperty and only callsregisterPushToStartToken(_:)fromdidActivateAblyPush.This works, but it is an undocumented contract encoded only in the structure of the examples. #2219's header doc-comment ("Use this when the device has already been activated") is the first place the requirement is stated at all, and even that does not explain the consequence of ignoring it.
The requirement is applied inconsistently across our materials
The gating contract ("register supplementary tokens only after
didActivateAblyPush") is neither stated as a contract anywhere nor followed uniformly. Full inventory of what each source currently does:Example apps — both gate:
ably-cocoaexample,Examples/AblyPush/AblyPushExample/AblyHelper.swift(@0fec56c), location — ✅ gates:activateLocationPush()is called from insidedidActivateAblyPush.AblyPushManager.swift(ttypic/liveactivity-example@2e6f6c3, linked from Add API for registering Live Activity push-to-start token #2219), push-to-start — ✅ gates: holds the token inpendingPushToStartTokenand only callsregisterPushToStartToken(_:)fromdidActivateAblyPush.Docs — one gates, one doesn't:
src/pages/docs/push/live-activities.mdx(#3464,docs@2342df7) — ✅ gates / mentions it: instructs registering the push-to-start token only afterdidActivateAblyPush.src/pages/docs/push/getting-started/apns.mdxStep 6 (docs@2342df7) — ❌ does not gate / silent:enableLocationPushis a standalone UI action (and is also triggered fromlocationManagerDidChangeAuthorization), independent of activation;didUpdateAblyPushis used only to report the token result. A user who enables location push before/during activation lands directly in the race window.SDK public-API doc-comments (
Source/include/Ably/ARTPush.h,@0fec56c) — the two supplementary token types are documented differently:registerPushToStartToken:(new in Add API for registering Live Activity push-to-start token #2219, L95–L103) —-activatehas completed)", stating the precondition but not the consequence of ignoring it.didRegisterForLocationNotificationsWithDeviceToken:/didFailToRegisterForLocationNotificationsWithError:(existing, L81–L93) — ❌ silent: describe only how to obtain the token; say nothing about any activation requirement.So even within a single header the two supplementary token types are documented inconsistently, and our example apps (which gate) contradict our location docs (which don't). If we keep the gating requirement, all of the above should say the same thing; if we remove the need for it (preferred — see Top-line), the
registerPushToStartToken:"use when already activated" caveat should be dropped too.Proposed fix
Make the gating unnecessary by not letting a supplementary-token
GotPushDeviceDetailscomplete activation while inWaitingForPushDeviceDetails. Options:WaitingForPushDeviceDetails, only proceed to the registration POST once the default token is present; otherwise treat the event as a recipient update and stay in the state. (May need a spec clarification of "the necessary push details" inRSH3a2c/RSH3b3.)didActivateAblyPush."Option 1 removes the burden for both token types; option 2 at least makes the existing requirement discoverable.
Either way, the location push docs (
apns.mdxStep 6) should be brought into line as a follow-up — if we go with option 1 no change is needed, and if we go with option 2 the docs should show the gating that the bundled example app already does. (Tracked here rather than as a separate docs issue.)Notes
Sources consulted
Pinned to the exact revisions inspected:
0fec56c8bf863ef89623066118b57e2b2b9097c3(head of PR Add API for registering Live Activity push-to-start token #2219; base53ffcb176b38602227da586b2e7b282c9fc936aeonmain). The state-machine files referenced are unchanged by Add API for registering Live Activity push-to-start token #2219 but are cited at the PR-head SHA since that is what was inspected.Source/ARTPushActivationState.m—ably-cocoa/Source/ARTPushActivationState.m
Lines 77 to 100 in 0fec56c
validateAndSync) and #L161-L181 (WaitingForPushDeviceDetails)Source/ARTRest.m—ably-cocoa/Source/ARTRest.m
Lines 855 to 871 in 0fec56c
setAndPersistAPNSDeviceTokenData:tokenType:)Source/include/Ably/ARTPush.h—ably-cocoa/Source/include/Ably/ARTPush.h
Lines 81 to 103 in 0fec56c
registerPushToStartToken:doc-comment L95–L103)Examples/AblyPush/AblyPushExample/AblyHelper.swift— https://github.com/ably/ably-cocoa/blob/0fec56c8bf863ef89623066118b57e2b2b9097c3/Examples/AblyPush/AblyPushExample/AblyHelper.swift2e6f6c376d7e7eb3a13ef4983c7dba61b80b21e8LiveActivityExample/LiveActivityExample/Services/AblyPushManager.swift— https://github.com/ttypic/liveactivity-example/blob/2e6f6c376d7e7eb3a13ef4983c7dba61b80b21e8/LiveActivityExample/LiveActivityExample/Services/AblyPushManager.swift6dc3ac9c755c32e4d17d141f718eb057c1c08ffaspecifications/features.md(RSH3activation state machine) — https://github.com/ably/specification/blob/6dc3ac9c755c32e4d17d141f718eb057c1c08ffa/specifications/features.md2342df723a895402b17a0dbe7fea725c4cfd9224(head of docs PR #3464;mainata27fa7991210db7f83b191861edec4400b7a56c4).apns.mdxis unchanged by #3464.src/pages/docs/push/getting-started/apns.mdx(location push, Step 6) — https://github.com/ably/docs/blob/2342df723a895402b17a0dbe7fea725c4cfd9224/src/pages/docs/push/getting-started/apns.mdx#L511src/pages/docs/push/live-activities.mdx(new Live Activities page, PR #3464) — https://github.com/ably/docs/blob/2342df723a895402b17a0dbe7fea725c4cfd9224/src/pages/docs/push/live-activities.mdx