Skip to content

SDK-496 Fix inAppConsume behavior and add JSON-only message signal - #1087

Merged
sumeruchat merged 16 commits into
masterfrom
fix/SDK-496-inapp-consume-inbox-notification
Jul 30, 2026
Merged

SDK-496 Fix inAppConsume behavior and add JSON-only message signal#1087
sumeruchat merged 16 commits into
masterfrom
fix/SDK-496-inapp-consume-inbox-notification

Conversation

@sumeruchat

@sumeruchat sumeruchat commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

This PR addresses SDK-496 by fixing local in-app consumption and inbox notifications, adding durable JSON-only availability, and hardening identity switching.

The design and delivery contract are documented here: https://iterable.slab.com/posts/changes-to-in-app-inbox-notifications-and-json-only-message-behavior-ios-gvzb5sco

Changes

  • Public inAppConsume now removes the message locally, sends the consume request, and posts iterableInboxChanged after local state changes only for inbox messages. Add and remove paths ignore non-inbox messages, and silent-push removal persists the post-removal list.
  • JSON-only messages are saved to UserDefaults before the main-thread delegate method onJsonOnlyMessageAvailable(message:) and notification iterableJsonOnlyInAppMessageAvailable fire. getUnhandledJsonOnlyMessages() recovers pending messages, which replay on foreground until markJsonOnlyMessageHandled(messageId:) acknowledges them.
  • The per-user queue clears on logout or user switch, honors expiresAt, and applies 30 day retention, 100 messages per user, and immediate triggers only. Those limits are SDK implementation defaults on a local queue, not a public contract.
  • An acknowledged ID with an unchanged payload stays suppressed while its fingerprint record is retained, a changed payload is delivered as a new record, and an unacknowledged expired or evicted ID is not readmitted while its discard record is retained. The latest 100 acknowledgements and 100 discard records per user are retained, oldest forgotten first. This state applies only to JSON-only messages, so HTML reuse of an ID is unaffected.
  • IdentityCoordinator scopes fetch, delivery, queued processing, and auth reads to the identity that started them. Stale work is discarded, and within a running process public getters do not return messages from another user.

Impact

There are no API surface breaking changes and no new dependencies. The six intentional behavior changes are:

  • inAppConsume callers now get the documented local removal.
  • iterableInboxChanged no longer fires for non-inbox additions, removals, or read-marking, so showing a popup no longer announces an inbox change.
  • onNew for a JSON-only message now fires asynchronously on the main thread, at most once, instead of synchronously during fetch processing.
  • In a mixed fetch, JSON-only messages are delivered in server arrival order before HTML display selection, while HTML keeps priority ordering.
  • JSON-only availability is delivered while auto-display is paused or during popup cooldown because those gates apply only to HTML.
  • A callback already in flight for the previous user may complete after a concurrent identity switch; the SDK revalidates when it returns and performs no further delivery step for it. Callbacks hold no SDK lock, so they may call any SDK API from any thread.

Eligible queue insertion uses one UserDefaults write per fetch batch, first-delivery state persists once per record, and the JSON store is consulted on every identified-user fetch; HTML rendering paths are otherwise unchanged.

Known pre-existing limitation: the normal in-app list on disk has no identity label, so a hard process kill between a user switch and queued cleanup can make the previous list load as the current user until the next sync; in-process reads are identity-gated, and the identity-labeled JSON-only queue does not have this gap. Scope is frozen at this revision; further non-regression findings are tracked as follow-up tickets.

Testing

  1. Run ./agent_build.sh.
  2. Run ./agent_test.sh.

The local full run is green with unit 693, notification-extension 12, offline-events 96, and 0 failures; CI can flake on three unrelated SDK-99 asynchronous disablePush tests that predate this change.

Fix tests verify post-mutation notification ordering and no notification for non-inbox removal. A 37-test InAppTests suite covers persistence before signaling, ordering, fingerprint acknowledgement and readmission, cold-start recovery, replay, deduplication, identity races, display gates, cross-type ID reuse, consume failures, expiry, retention, capacity, tombstones, and batch persistence. Objective-C compile tests cover both the legacy conformer and the new API surface.

The public inAppConsume overloads sent the server request but never
removed the message locally or posted iterableInboxChanged, despite
their docs saying they remove it from the list. They now route through
InAppManager.remove, which does all three. Inbox change notifications
also fire only after the local mutation completes, so observers no
longer read stale message counts; removePrivate and silent-push
removal previously posted on a different queue than the mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sumeruchat
sumeruchat requested a review from a team as a code owner July 21, 2026 10:47
@sumeruchat sumeruchat self-assigned this Jul 21, 2026
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.12763% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.67%. Comparing base (dd6e178) to head (ed110af).

Files with missing lines Patch % Lines
swift-sdk/Internal/in-app/InAppManager.swift 92.85% 21 Missing ⚠️
swift-sdk/Internal/in-app/InAppPersistence.swift 94.64% 12 Missing ⚠️
swift-sdk/Internal/EmptyInAppManager.swift 20.00% 4 Missing ⚠️
swift-sdk/Internal/IterableUserDefaults.swift 0.00% 4 Missing ⚠️
swift-sdk/Internal/Utilities/LocalStorage.swift 0.00% 4 Missing ⚠️
...t-sdk/Internal/in-app/InAppManager+Functions.swift 94.23% 3 Missing ⚠️
swift-sdk/Internal/InternalIterableAPI.swift 98.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1087      +/-   ##
==========================================
+ Coverage   72.32%   73.67%   +1.34%     
==========================================
  Files         114      114              
  Lines        9594    10202     +608     
==========================================
+ Hits         6939     7516     +577     
- Misses       2655     2686      +31     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

sumeruchat and others added 2 commits July 21, 2026 12:15
Removing a popup or JSON-only message announced an inbox change that
never happened. removePrivate and silent-push removal now post only
when the removed message was an inbox message. Silent-push removal
also persisted the pre-removal message map because the value-type
snapshot was captured before removeValue; it now persists the map
after removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JSON-only messages were handed to onNew once and auto-consumed, with
no way to recover a payload the app missed during a cold start or
background fetch; customers polled getMessages and diffed. Adds an
availability contract: an optional onJsonOnlyMessageAvailable delegate
method and iterableJsonOnlyInAppMessageAvailable notification, both on
the main thread, backed by a durable identity-scoped unhandled queue
persisted before either signal fires. Delivery is at least once until
the app acknowledges via markJsonOnlyMessageHandled; unhandled records
replay on foreground and are queryable with
getUnhandledJsonOnlyMessages. Retention defaults (30 days, 100 records
per identity, clear on identity change, immediate triggers only) are
working values pending product confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sumeruchat sumeruchat changed the title SDK-496 Fix inAppConsume local removal and notification SDK-496 Fix inAppConsume behavior and add JSON-only message signal Jul 21, 2026
sumeruchat and others added 6 commits July 22, 2026 17:14
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unhandled queue pruned expired records only on load, but enqueue and
prepareDelivery appended and returned a message without checking expiry.
A message fetched while valid could expire before the next foreground
replay and still be signaled to the app. Reject an already-expired
message in enqueue and prepareDelivery using the same rule loadCurrentState
applies, so an expired JSON-only message is never delivered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes three gaps in the JSON-only availability path found in review.

A message enqueued for user A could be persisted and signaled under
user B if the identity switched during the async hop to the main
queue: prepareDelivery resolved the live identity and its
append-if-missing branch recreated the record under the new user.
Store operations now validate against the identity captured at the
start of the processing run and fail closed on mismatch, and
prepareDelivery only delivers records that already exist.

Active fetch also persisted eligible JSON-only records one at a time,
so the first availability callback could not see later records from
the same response. All eligible records are now enqueued before any
signal, on both the active and background paths.

Initial active delivery followed display priority; it now follows
server arrival order, matching the replay path and the documented
contract. HTML display selection keeps priority ordering.

Duplicate message IDs keep the first stored payload until
acknowledgement; a test now pins that behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fetch issued for user A whose response resolved after a switch to
user B was processed under B: the identity scope was captured only
after the network call returned, so A's messages could land in B's
message map and JSON-only queue. The scope is now captured before the
fetch and the whole response is discarded as a no-op when the identity
has changed by the time it resolves, without updating lastSyncTime.

Also: the JSON-only store no longer rewrites persisted state on reads
that changed nothing, and jsonOnlyMessageQueueData lost its silent
no-op protocol default so every conformer must implement storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user switch could race in-app processing at several boundaries: a
fetch response resolving after the switch, delivery callbacks running
mid-switch, queued HTML processing for the previous user, and public
getters reading the old map. An IdentityCoordinator (recursive
critical section plus a monotonic generation) now scopes every stage:
fetch commits merge and map assignment atomically against identity
publication, JSON-only delivery revalidates between each customer
callback and before the consume mutation, HTML processing prechecks
context before the display gate and before onNew without holding the
lock across customer code, and getters read context and map in one
section. Customer and display callbacks run on a dedicated processing
queue instead of the sync queue.

Acknowledgements are durable: markJsonOnlyMessageHandled records the
message ID with a canonical payload fingerprint (deployment-neutral
recursive encoder, iOS 10 safe), so an acknowledged unchanged payload
stays suppressed, a changed payload readmits, and retention or
capacity eviction of an unacknowledged record cannot readmit it.
Acknowledgement and tombstone state apply to JSON-only messages only;
an HTML message reusing an ID is unaffected, and cross-type
transitions cannot halt batch processing. Server read-state overwrites
no longer clear local consumed state, readmitted records reach
delivery tracking, and eligible records persist in one batch write.

The JSON-only callbacks run inside the identity critical section; the
public docs on onNew, onJsonOnlyMessageAvailable, and the notification
state the resulting cross-thread wait restriction. Objective-C gains
IterableAPI.jsonOnlyInAppMessageAvailableNotification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@joaodordio joaodordio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The code quality and test depth are genuinely very good!

I'm marking as CR so we can make a clear decision on the In AppManager with the callback-under-lock design!

Comment on lines +743 to +767
if delivery.isInitial {
guard self.identityCoordinator.performIfCurrent(identityContext,
identityProvider: self.identityProvider, {
_ = self.inAppDelegate.onNew(message: delivery.message)
}) else {
result.resolve(with: false)
return
}
}
guard self.identityCoordinator.performIfCurrent(identityContext,
identityProvider: self.identityProvider, {
self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message)
}) else {
result.resolve(with: false)
return
}
guard self.identityCoordinator.performIfCurrent(identityContext,
identityProvider: self.identityProvider, {
self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable,
object: delivery.message,
userInfo: nil)
}) else {
result.resolve(with: false)
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These callbacks run while holding the identity lock, which is a deadlock risk.

onNew, onJsonOnlyMessageAvailable, and the notification post are passed as the block to performIfCurrent, which executes that block inside withCriticalSection's NSRecursiveLock (Auth.swift:33, 61-65, 75), on the main thread (InAppManager.swift:790-794). So the customer callback runs with the identity lock held.

IterableAPI.email and auth (which most track/network calls build their request from) re-enter that same lock through identityValues() (InternalIterableAPI.swift:21, 81-82, 975-977). So a callback that hops to another queue and synchronously waits while that work reads an identity API deadlocks: the worker thread blocks on the lock main holds, and main is blocked on the sync. Same-thread reentry is fine thanks to the recursive lock, but a cross-thread synchronous wait is an easy pattern to hit in a callback whose whole job is to hand the customer a payload to process.

Can we snapshot what we need under the lock, release it, invoke the customer callback outside the critical section, then re-validate identity (generation + snapshot) after it returns? If we keep it as-is, the restriction needs to be a lot louder in both the delegate header and docs PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in 6200375. The three surfaces now run with no SDK lock held: check identity before, invoke unlocked, revalidate on return; a stale result stops everything after that surface, and the mutation/consume guard is unchanged. The cross-thread wait restriction is gone from the public docs, replaced by the actual contract: a callback already selected for the previous user may complete after a concurrent switch, and no later SDK step runs for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch btw!

Comment on lines +975 to +977
private func identityValues() -> (email: String?, userId: String?) {
identityCoordinator.withCriticalSection { (_email, _userId) }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the re-entry point that closes the deadlock loop: identityValues() takes the same identity lock, so any identity read (email, auth, and track via auth) called from a callback-spawned thread while the JSON-only callback holds the lock on main will block here. See the callback-under-lock comment in InAppManager.swift:754.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved by the same change (6200375): callbacks no longer hold the identity lock, so this re-entry can no longer close a deadlock loop.

sumeruchat and others added 2 commits July 24, 2026 13:48
Removes scaffolding left by iterative hardening: the dead
skipAndConsume processor case, the processAndShowMessage entry guard
subsumed by the later per-boundary checks, and the store's test-only
default coordinator and no-context wrappers. Restores unrelated EOF
whitespace.

The readmission test intermittently observed the first message's
delivery track through the second message's callback because the mock
network session invokes callbacks asynchronously; the test now waits
for the initial track before acknowledging, so the two phases are
serialized without disabling over-fulfillment checks. No production
tracking behavior changed. Also bounds the CHANGELOG acknowledgement
claim to the retained metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts the review request on the delivery design: onNew, the
availability delegate, and the notification post are now invoked with
no SDK lock held. Each surface is prechecked against the captured
identity and generation, invoked unlocked, and revalidated on return;
a stale result stops every later surface, mutation, delivery tracking,
and consume. This removes the deadlock where a callback synchronously
waiting on another thread that touches an identity-reading SDK API
blocked forever, and with it the public cross-thread wait restriction.
The replacement contract, documented on the delegate methods and the
notification, is that a callback already selected for the previous
user may complete after a concurrent switch, with no subsequent SDK
step performed for it. Concurrency tests now assert the switch
completes while a callback is paused and the suffix stays suppressed
at every boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sumeruchat
sumeruchat requested a review from joaodordio July 24, 2026 14:04
…onsume-inbox-notification

* origin/master:
  SDK-562 Fix offline queue replaying expired JWT (#1085)
  SDK-569 Parse itbl delivered as a JSON string (iOS-via-FCM) (#1086)

# Conflicts:
#	CHANGELOG.md

@joaodordio joaodordio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for addressing the changes! Much better now.

Let's get the CI to pass green on all tests specifically on the one I've commented on.

Can we please also fix these:

  • Remove or resolve // Product defaults pending confirmation. for the 30-day / 100 caps.
  • set(read:) still posts iterableInboxChanged for any message. Minor inconsistency with the new inbox-only rule.

persister.clear()
}

func testInboxAndInAppCallbacksTogether() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test is failing on CI with count 2 on the first iterableInboxChanged callback. The new fetch pipeline resolves the Pending after finishSync posts to callbackQueue, and this observer then hops to main.async before reading getInboxMessages(). That lets payload 2 land before the first callback runs, so the first fulfillment sees both inbox messages. Either wait for the first inbox callback (and the first onNew) before sending payload 2, or assert on the callback queue without the extra main.async.

Let's get this green before merge since it sits on the path this PR reworked.

JSON-only replay was added to start() reading
applicationStateProvider.applicationState directly, so when start()
ran off the main thread the SDK made an invalid off-main read of
UIApplication.applicationState. Under Xcode 16.x, which runs async
XCTest bodies on a concurrency worker, this terminated the test host
and crashed the three async disableDevice tests in CI; the same access
would be invalid for any app initializing the SDK off the main thread.
Replay now resolves the active state through a main-thread helper,
mirroring the existing getAppIsReady hop but without its display gate,
so JSON-only data replay stays decoupled from HTML display state. The
regression test asserts the read happens on the main thread, so it
fails deterministically on any toolchain rather than depending on
scheduling.

Also hardens InboxTests.testInboxAndInAppCallbacksTogether, which had
no happens-before edge between observing the first inbox notification
and submitting the second payload, so the deferred observer could read
the later count. It now waits for the first notification and its
assertion before the second fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sumeruchat

sumeruchat commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

8003bedf added JSON-only replay to start() reading applicationStateProvider.applicationState directly. Off-main that is an invalid UIApplication.applicationState read, and Xcode 16.x runs async XCTest bodies on a concurrency worker, so the three async disableDevice tests terminated the test host: no assertion, run restarts mid-class. Fixed in 14596663 by resolving the state through a main-thread helper, mirroring getAppIsReady without its display gate. The regression test asserts the read happens on main, so it fails deterministically rather than by scheduling luck. It never reproduced locally because CI pins Xcode 16.4 and I was on 26.6.

InboxTests.testInboxAndInAppCallbacksTogether was separate and pre-existing: no happens-before edge between the first inbox notification and the second payload, so the deferred observer read the later count. Hardened in the same commit, no assertion weakened. CI is green on 14596663, both required checks.

Your two follow-ups are in 10113ae7:

  • set(read:) now posts iterableInboxChanged only for saveToInbox messages, so showing a popup no longer announces an inbox change. Tests cover both directions. Note this is a behavior change beyond what the PR claimed before, so the design doc now lists read-marking as gated; reset() and sync overwrite still post unconditionally.
  • Dropped the provisional marker on the 30 day / 100 record limits. They are SDK limits on a local queue rather than a public contract, and the doc and PR body say so.

sumeruchat and others added 3 commits July 29, 2026 11:20
set(read:) posted iterableInboxChanged for every message, so showing a
popup announced an inbox change even though the message was never in
the inbox. It is now gated on saveToInbox like the add and remove
paths. reset() and sync overwrite still post unconditionally.

Also drops the provisional marker on the JSON-only queue limits. The
30 day retention and 100 record caps are SDK limits on a local queue
rather than a public contract, and are documented as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renames consumeOnReplay to consumePreviouslyDelivered. The flag means
consume even though initial delivery already began, so the old name
read backwards at the branch that decides it.

Adds rationale comments where the constraint is not visible from the
code: why identity publication is announced before taking the lock and
why the announcement spans logout and republication, what the three
processing phases are and what messagesRevision guards, why the
per-callback identity checks cannot be collapsed, why JSON-only
delivery bypasses the HTML display gate, why one merge branch must
precede another, and why a missing unhandled record is never
recreated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test gave all queued requests the same JWT failure response and relied on Core Data preserving insertion order for equal scheduled timestamps. Under contention an unauthenticated task could run first, receive the JWT failure, and stop the runner before the test installed its success observer.

Assign explicit task ordering, configure only the first authenticated request to fail, and register the success observer before starting the runner so the test deterministically exercises unauthenticated draining during auth pause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sumeruchat
sumeruchat merged commit 6f97321 into master Jul 30, 2026
15 checks passed
@sumeruchat
sumeruchat deleted the fix/SDK-496-inapp-consume-inbox-notification branch July 30, 2026 15:53
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.

2 participants