fix: read-your-write consistency defects - #1706
Conversation
Canonical sort applied by the xcodeproj tool, so later commits in this stack show only their own file additions. No project changes. Co-authored-by: Cursor <cursoragent@cursor.com>
Four faults in how a fetch waits for its own write to be readable: resolveConditionsWithID looked up waiters by condition id, but they are registered under the id passed to getRywTokenFromAwaitableCondition, so the lookup found nothing and the waiter it meant to release stayed blocked. It now scans every index for waiters on that condition. A waiter blocked on an unbounded semaphore, so a response that never arrived held the calling thread for the life of the process. Waits now time out and deregister rather than leaving an entry that the next token signals to nobody. OSIamFetchReadyCondition was a singleton pinned to the first id it ever saw, so after a user switch a fetch consulted the previous user's tokens. Conditions are now per id, with reset() as the test seam. hasSubscriptionUpdatePending was never lowered, so one in-session subscription change held every later fetch to waiting for a subscription token with no update behind it. The new optional onConditionSatisfied lets a condition lower a bar it raised once its waiter is released. Shared state moves behind the serial queue and locks throughout. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Multimodal adversarial review (interrogate)
Verdict: The four named defects are real and largely hit, but two of the fixes reintroduce the same class of failure under slightly different shapes. Highest confidence from consensus across 4 models.
Intent
Fix four IAM read-your-write defects: broken
resolveConditionsWithIDlookup, unbounded waiter blocking, singleton condition answering the wrong user after switch, and a stuckhasSubscriptionUpdatePendingbar — via cross-index resolve, 30s timeout + deregister, per-id conditions, andonConditionSatisfied.
Reviewers
- A:
claude-fable-5-thinking-xhigh— 7 findings - B:
gpt-5.6-sol-xhigh— 4 findings - C:
cursor-grok-4.5-high-fast— 3 findings - D:
claude-opus-5-thinking-high— 9 findings
Act On
- Cross-user resolve broadcast (A/B/C/D) —
resolveConditionsWithIDscans every index and releases every waiter withconditionId == "OSIamFetchReadyCondition", then clears every user's subscription bar viaonConditionSatisfied. That undoes per-id isolation under login/user-switch overlap. All four executor call sites already have the relevantonesignalIdin scope. - Timeout never lowers the subscription bar (A/B/C/D) — timeout deregisters the waiter but skips
onConditionSatisfied. After a failed/missing subscription update, every later IAM fetch for that id pays another full 30s. Defect (4) survives as a permanent per-fetch tax. - Boolean bar + unconditional clear cannot represent overlapping subscription writes (A/B/C/D) —
hasSubscriptionUpdatePendingis a single bool; any waiter release clears it. A second in-flight subscription change can be dropped when the first token/release fires. StalesubscriptionUpdatetokens also make later arms inert becauseisMetonly checks presence.
Consider
- Make the no-
ryw_tokenfallback level-triggered (record a sentinel) so a response that lands before the fetch registers does not force a full timeout (A/D). OSSubscriptionOperationExecutorupdate path stores tokens under currentonesignalId, notrequest.identityModel.onesignalId(B) — adjacent cross-user footgun at the same boundary.- Flaky short-timeout tests that assert
waiterCount == 1while the clock is already running (A/D). - Call
onConditionSatisfiedoutside the serial queue to avoid future deadlocks if a conformer re-enters the manager (D).
Noted
- Per-id condition map never evicted in production (A/D).
- Spurious timeout warn if release races deregistration (A).
static var waitTimeoutas unsynchronized test seam (D).- Missing
import FoundationinOSIamFetchReadyCondition.swift(D).
Dismissed
- Broad “rewrite waiters with async/await” style preferences — out of scope; ObjC call site in
OSMessagingController. - File-size / abstraction nits — not applicable here.
Agreement map
All four models independently flagged (1) cross-user resolve fan-out and (2) timeout leaving the bar raised. Three-plus also flagged the boolean/generation problem. Divergence was mostly on severity labels and on whether the edge-triggered resolve race is Act On vs Consider.
Skill: Cursor interrogate (pstack).
Sent by Cursor Automation: Untitled
resolveConditionsWithID walked every index and cleared every matching condition's subscription bar, so a response with no ryw_token for one user could unblock — and disarm — another. Resolve now takes the onesignal id and only touches that bucket. A timed-out waiter only deregistered itself, leaving hasSubscriptionUpdatePending up, so later IAM fetches for that id paid another full wait for a subscription token that was never coming. Timeout now runs the same onConditionSatisfied clear, if the waiter is still registered. Co-authored-by: Cursor <cursoragent@cursor.com>
Cut hazard essays and cross-user narration down to short whys hitched to the action, and trim the resolveConditions doc to the contract. Co-authored-by: Cursor <cursoragent@cursor.com>
6aec0fa to
c09d5a9
Compare


Description
One Line Summary
Fixes a set of bugs in how OneSignal makes sure an in-app message fetch sees your own recent changes (a "read-your-write" wait)
project.pbxprojso future diffs aren't buried in Xcode's reorder noise.(This targets
main, not the Identity Verification/JWT work.)Details
Motivation
When you update your profile or subscription, the SDK briefly waits before fetching in-app messages, to make sure that fetch reflects what you just wrote instead of stale data. That waiting logic had several sharp edges:
Fixed here, across two passes: an initial fix, plus a follow-up that closed gaps the first one left (the "release across indexes" and "bar not lowered on timeout" cases above).
main'sproject.pbxprojwas also out of the gem's canonical UUID order, so the first PR to touch the project file would otherwise bury real changes in reorder noise. Landing that normalize here (no separate PR) keeps follow-ups focused.Scope
OSConsistencyManager).resolveConditions(conditionId:forId:)only touches that user's waiters, never another's.OSIamFetchReadyCondition).OSCondition.onConditionSatisfied).ConsistencyManagerTestHelpers,XCTestCase+WaitUntil) so future consistency tests don't need to reinvent polling/reset logic.project.pbxproj— pure formatting, no source or behavior changes.Testing
Unit testing
OSConsistencyManagerTestsOSIamFetchReadyConditionTestsConsistencyManagerTestHelpers.reset()Manual testing
Built
UnitTestAppfor iOS Simulator as part of the local JWT stack; this PR's commits compiled in sequence.Affected code checklist
Checklist
Overview
Testing
Final pass