Skip to content

SDK-392 Add defensive timeout to iOS initialize to prevent promise hang#833

Open
sumeruchat wants to merge 1 commit intomasterfrom
fix/SDK-392-defensive-init-timeout
Open

SDK-392 Add defensive timeout to iOS initialize to prevent promise hang#833
sumeruchat wants to merge 1 commit intomasterfrom
fix/SDK-392-defensive-init-timeout

Conversation

@sumeruchat
Copy link
Copy Markdown

What

Adds a 10-second defensive timeout to ReactIterableAPI.initialize() on iOS so the JS promise resolves even if the native IterableAPI.initialize2 callback is never invoked.

Reported by: CarGurus (SDK-392) — Iterable.initialize() promise hangs indefinitely on React Native New Architecture.

Root Cause

In New Architecture bridgeless mode, RCTEventEmitter events may not reach JS (bridge is nil). When authHandler is configured with a saved user, the native SDK's onAuthTokenRequested sends an event to JS and blocks for 30s — but JS never receives it. The nil auth token causes a 401 JWT error on the in-app fetch, which enters a retry loop in the Swift SDK's RequestProcessorUtil. When retries are exhausted, a bug in AuthManager.requestNewAuthToken() causes the Pending/Fulfill to never resolve — so the initialize2 callback never fires, and the JS promise hangs forever.

Companion fix: The underlying Swift SDK bug is addressed in Iterable/iterable-swift-sdk#1023. This timeout is a defensive workaround that:

  • Unblocks customers on older Swift SDK versions
  • Covers other edge cases where the callback never fires (network hangs, etc.)
  • Resolves the promise faster than waiting for maxRetry × retryInterval + 30s auth timeout

Changes

  • ios/RNIterableAPI/ReactIterableAPI.swift: Wrap the resolver in a resolveOnce guard and schedule a 10-second timeout via DispatchWorkItem. If the native callback fires normally, the timeout is cancelled. If not, the promise resolves with true after 10s. The SDK still initializes and functions normally regardless — only the JS promise is unblocked.

Regression Risk Analysis

Low risk

  • Normal init (callback fires within 10s): Timeout is cancelled, resolveOnce ensures resolver is called exactly once with the real result. No behavior change.
  • Android: Unaffected — Android initialize resolves synchronously.

Medium risk — review carefully

  • Init takes >10s legitimately: If the native SDK callback genuinely takes >10s (e.g. very slow network for in-app fetch), the promise resolves with true before the actual result is known. The SDK is still initializing in the background. Callers that depend on the promise result to know if init truly succeeded would get a false positive. In practice, CarGurus confirmed the SDK does initialize and respond to lifecycle events even when the promise hangs — so resolving with true matches actual behavior.

Not affected

  • All other native module methods (they don't go through this init path)
  • Old Architecture (bridge exists, events reach JS, callback fires normally)

Testing

How to test:

  1. Enable New Architecture in the example app
  2. Configure IterableConfig with authHandler set
  3. Have a saved user (email or userId) in UserDefaults
  4. Call Iterable.initialize() and verify the promise resolves within ~10s
  5. Verify the SDK still functions (can track events, receive in-apps, etc.) after timeout resolution
  6. On Old Architecture, verify normal init behavior is unchanged (callback fires, timeout is cancelled)

…(SDK-392)

On React Native New Architecture (bridgeless mode), the IterableAPI.initialize2
callback may never fire if auth token retry is exhausted in the native SDK,
leaving the JS promise pending indefinitely. This adds a 10-second timeout
that resolves the promise if the native callback is not invoked in time.

The SDK still initializes and functions normally — the timeout only unblocks
the JS promise so downstream app logic (e.g. iterableReady) is not stalled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Lines Statements Branches Functions
Coverage: 63%
63.07% (398/631) 40.07% (103/257) 61.5% (139/226)

@qltysh
Copy link
Copy Markdown

qltysh bot commented Apr 2, 2026

Qlty

Coverage Impact

This PR will not change total coverage.

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

// Architecture bridgeless mode, or network fetch hangs).
let timeoutWorkItem = DispatchWorkItem {
ITBInfo("initialize timeout reached – resolving promise to unblock JS")
resolveOnce(true)
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 tells the JS layer initialization succeeded when it actually timed out. With the iOS fix now merged, this timeout should theoretically never fire. But if it does (some other unforeseen hang), masking the failure with true is incorrect.

If the timeout fires, tje initialization did not complete successfully and the app should know.
The point is to unblock the JS promise, bit the result should be accurate.

Suggested change
resolveOnce(true)
resolveOnce(false)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good callout, but I think true is actually the more accurate result here. Here's why:

In IterableAPI.initialize2 (IterableAPI.swift:126-129), the SDK is initialized synchronously before start() is called:

implementation = InternalIterableAPI(apiKey: apiKey, ...)  // ← this IS init
_ = implementation?.start().onSuccess { callback?(true) }  // ← this is in-app sync

The callback is gated on implementation.start()inAppManager.start()fetcher.fetch() — that's the in-app message sync, not actual SDK initialization. The SDK is fully functional at that point.

Jena confirmed this from the debug session: "we did see the SDK respond to lifecycle events (background, foreground) so it was init despite the hang."

If this timeout fires, the most likely scenario is:

  • The SDK did initialize successfully
  • The callback just never propagated (the Fulfill bug we fixed in the Swift SDK, a network hang, etc.)

Resolving with false would tell the app "Iterable failed to initialize" — which could cause it to skip Iterable functionality entirely, even though the SDK is actually working. That's a worse outcome for the customer.

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.

3 participants