Skip to content

fix: [SDK-5137] in-app message fetch wait follow-ups from the JWT review - #1744

Open
nan-li wants to merge 4 commits into
5.8-mainfrom
nan/sdk-5137-consistency
Open

nan-li wants to merge 4 commits into
5.8-mainfrom
nan/sdk-5137-consistency

Conversation

@nan-li

@nan-li nan-li commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Three follow-ups from the review of #1706, all in how an in-app message fetch waits for the writes before it. Tracked on SDK-5137.

Details

Motivation

Review feedback on the Identity Verification stack was collected on SDK-5137 instead of amending the stacked branches. This PR takes the three items from the consistency manager review on #1706. #1740 and #1743 cover the rest.

Scope

Each fix is its own commit with its own test.

  • A write whose response has no ryw_token is now recorded like any other. Before, the executor released only the fetches already waiting at that moment, so a fetch that registered a little later waited the full timeout for a token that was never coming. On a first launch that is the normal order of events, since the Create User response schedules the fetch through two queue hops and releases waiters before the fetch has registered. The four executors now file a blank entry under their own key, the fetch condition only checks that the entry is there, and the separate release call is gone. This only matters if the API stops returning ryw_token; today the stored token already covers a late fetch.
  • A fetch that times out now re-checks the other fetches waiting on the same user. Timing out lowers the bar that an in-session subscription change raised, and that bar is shared, so a sibling fetch that already had its user token could have gone out at that moment. Nothing re-checked it, so it sat out its own timeout, up to 30 seconds, and then went out with the token it had all along.
  • The two tests that prove the bar comes back down now fail if it does not. One kept the subscription token on file, so the second fetch was released by that token whether the bar was up or not. The other gave the second fetch the same short timeout as the first, so it could return by timing out again.

One behavior change to know about. A fetch held for an in-session subscription change now waits for that subscription write to complete, with or without a token, where before any tokenless response for the user released it. That is what the bar exists for, and the timeout still bounds the wait.

Follow-ups from review, each its own commit:

  • Adding an email or SMS subscription no longer wipes out the read-your-write token from an earlier subscription write. The create-subscription response carries its token next to the subscription object, and the handler read it from inside that object, so it always came back empty. Before this PR that was harmless. With the first fix above, the empty result was recorded over a real token, and the next fetch could miss the subscription change.

Not changed: request signing, what the fetch request contains. One method does go away from the OneSignalOSCore framework, the release call the executors no longer need, shipped in 5.6.1 as resolveConditionsWithID. Nothing outside the SDK calls it. Android still uses it and does not record a write that returns no token, so the two platforms differ here on purpose.

Testing

Unit testing

For the first fix, the new ExecutorReadYourWriteTests drives each of the four executors with a tokenless response and checks that the fetch condition is met afterwards. All four fail on the previous commit. The manager and condition tests that covered the old release call are rewritten against the blank entry. For the second fix, the new sibling test in OSIamFetchReadyConditionTests fails on the previous commit. For the third, the two tightened tests were run with bar lowering disabled in the manager and both failed. For the follow-up, the same file checks that a create-subscription response carrying a token records it for the fetch; that test fails on the previous commit.

Manual testing

Not run on a device. The full unit test plan was run locally on an iPhone 17 Pro simulator. Xcode 27 rejects the project's iOS 12 deployment target, so the local build overrode it on the command line; nothing in the project changed for that.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

🤖 Generated with Claude Code

…ays up

The manager-release test kept the subscription token on file, so the second fetch was released by
that token whether or not the bar had come down. It now asserts the bar is down after the release,
forgets the tokens, and has a fresh user token release the second fetch on its own.

The timeout test gave the second fetch the same short timeout as the first, so it could return by
timing out again. It now asserts the bar is down after the timeout and restores the full timeout,
so the second fetch can only return by being released.
…times out

Timing out lowers the bar an in-session subscription change raised, and every fetch waiting on the
id shares that bar, so a sibling that already had its user token could go out at that moment. Nothing
re-checked it, so it sat out its own timeout, up to 30 seconds, and then went out with the token it
had all along. The timed-out waiter is deregistered first, so the re-check cannot release it twice.

The timeout is read before the waiter registers, so the test can shorten it for one waiter and
restore it before registering the next.
…sters later is not held for it

The executors released only the fetches already waiting when a response came back without a token,
so a fetch that registered a little later waited the full timeout for a token that was never coming.
On a first launch that is the normal order: the Create User response schedules the fetch through two
queue hops and releases waiters before the fetch has registered.

The four executors now file a blank entry under their own key, which the fetch condition counts as
the write having completed, and resolveConditions is gone. A fetch held for an in-session
subscription change now waits for that subscription write to complete, with or without a token,
where before any tokenless response for the user released it.

The manager's token map is readable through a mocks helper, so the executor tests can check what a
fetch would find on file.
…create-subscription response

The create-subscription response carries `ryw_token` and `ryw_delay` next to `subscription`, where the update path and Android read them. The create path read them out of the nested object, so it always filed nil. Now that a tokenless write files a blank entry, that blank replaced a real subscription token on every email or SMS add, and the next fetch went out without it.

Test: a create-subscription response with a top-level token reaches the map under the subscription key. Fails on the previous commit.
@nan-li
nan-li marked this pull request as ready for review September 18, 2026 01:24

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model review (Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6) of the IAM fetch-wait follow-ups.

Blank entries, the sibling timeout re-check, the tightened bar tests, and reading ryw_token beside subscription match the stated intent. No consensus blocker for the current API (it still returns ryw_token).

Act on
None that are both consensus and clearly unintended on today's path.

Consider

  • (2/3 Opus+Grok) A tokenless Create User now leaves a blank userCreate entry. isMet short-circuits on any userCreate presence, so a later in-session subscription change never holds the fetch. Old resolveConditions released only current waiters and left the map empty. A real create token already behaves this way — confirm the fallback should match that for the rest of the process.
  • (Grok; related Opus overwrite) subscriptionUpdate is one slot. Create-subscription now files there, so a leftover entry can satisfy a bar raised for a later push update. The bar-lowering test has to reset() for this. Same presence model as before once an update has already returned a token.
  • (Opus) setRywTokenAndDelay overwrites unconditionally. A later tokenless write under the same key drops a real token — the hazard the follow-up commit fixed for the nested-token misread.
  • (Opus) Timeout re-check uses the shared per-id bar. Waiter A timing out can release waiter B that registered after a newer subscription write was armed.
  • (2/3 Opus+Sol) Create-subscription still returns before filing when subscription is missing (documented already-exists empty body). Filing that blank could also satisfy a bar raised for a different write; add does not raise the bar itself.

Noted

  • (2/3) Sibling-timeout test uses a 1s setup window and can flake if the first waiter times out early.
  • (Sol warning / Opus nit) Removing @objc public resolveConditions is a framework ABI break; already called out, no in-repo callers.
  • (Opus) @testable import OneSignalOSCore from the Mocks framework target; Release has ENABLE_TESTABILITY = NO.

Dismissed

  • Cross-platform resolveConditions divergence — stated as intentional.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment on lines 364 to +371
if let onesignalId = request.identityModel.onesignalId {
if let rywToken = response["ryw_token"] as? String
{
let rywDelay = response["ryw_delay"] as? NSNumber
OSConsistencyManager.shared.setRywTokenAndDelay(
id: onesignalId,
key: OSIamFetchOffsetKey.userCreate,
value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay)
)
} else {
// handle a potential regression where ryw_token is no longer returned by API
OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId)
}
// Filed even when the response has no ryw_token, so a fetch that registers after this write
// is not held for a token that never comes.
let rywData = OSReadYourWriteData(
rywToken: response["ryw_token"] as? String,
rywDelay: response["ryw_delay"] as? NSNumber
)
OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.userCreate, value: rywData)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider (2/3 Opus+Grok): this files a blank under userCreate when ryw_token is missing. OSIamFetchReadyCondition.isMet returns true as soon as any userCreate entry exists, and indexedTokens is never pruned in production, so a later in-session subscription change will not hold the fetch.

Old resolveConditions released only waiters already registered and left the map empty. A real create token already short-circuits the same way — confirm the tokenless fallback should match that for the rest of the process. If not, treat a nil-token create as completion of create only (rywToken != nil for the short-circuit, or also require !hasSubscriptionUpdatePending).

Comment on lines 360 to +375
@@ -366,21 +366,16 @@ extension OSSubscriptionOperationExecutor {
}

if let onesignalId = request.identityModel.onesignalId {
if let rywToken = response["ryw_token"] as? String
{
let rywDelay = response["ryw_delay"] as? NSNumber
OSConsistencyManager.shared.setRywTokenAndDelay(
id: onesignalId,
key: OSIamFetchOffsetKey.subscriptionUpdate,
value: OSReadYourWriteData(rywToken: rywToken, rywDelay: rywDelay)
)
} else {
// handle a potential regression where ryw_token is no longer returned by API
OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId)
}
// Filed even when the response has no ryw_token, so a fetch that registers after this write
// is not held for a token that never comes. The token sits beside `subscription`, not inside it.
let rywData = OSReadYourWriteData(
rywToken: response?["ryw_token"] as? String,
rywDelay: response?["ryw_delay"] as? NSNumber
)
OSConsistencyManager.shared.setRywTokenAndDelay(id: onesignalId, key: OSIamFetchOffsetKey.subscriptionUpdate, value: rywData)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider (2/3 Opus+Sol; Grok on the leftover slot): the RYW file sits after the subscription guard. MockUserRequests.setAddEmailResponse documents that a real create-subscription response can be empty when the address already exists, so this path still files nothing — the hole this PR closes on the other three executors.

Filing before the guard is not free: create-subscription now occupies the one subscriptionUpdate slot, and isMet only checks presence. A leftover (or already-exists blank) can satisfy a bar raised for a later push update. getAddModelDelta does not raise the bar, so this mainly matters when an update is already in flight.

@nan-li
nan-li changed the base branch from 5.7-main to 5.7-main-temp September 18, 2026 16:41
@nan-li
nan-li changed the base branch from 5.7-main-temp to 5.8-main September 18, 2026 16:43
@nan-li
nan-li requested a review from a team September 21, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant