Skip to content

Commit bbc33dc

Browse files
committed
fix(SDK-520): scope auth latch per-invocation and raise auth timeout default
- Replace shared authLatchResolver/pendingAuthResult with a per-invocation FIFO queue so overlapping handleAuthCalled invocations no longer drop successCallback/failureCallback - Route native success/failure events to the oldest pending invocation and shift the queue synchronously on resolve - Raise IterableConfig.authCallbackTimeoutMs default 1000 -> 6000 with rationale in TSDoc - Remove dead AUTH_CALLBACK_TIMEOUT_DEFAULT_MS / ANDROID_WAKE_DELAY_DEFAULT_MS fallbacks; IterableConfig is the single source of truth - Update Iterable.test.ts default assertions (1000 -> 6000), rework safety-net test to use a custom short timeout - Add regression test: two back-to-back handleAuthCalled invocations assert both successCallbacks fire with no 'No callback received' warning - Update CHANGELOG default
1 parent 4c2c9e0 commit bbc33dc

4 files changed

Lines changed: 197 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
- Added `IterableConfig.androidWakeDelayMs` (default `1000`) to tune the
1818
Android deep-link wake delay before the SDK invokes `urlHandler`. Set to
1919
`0` to dispatch synchronously.
20-
- Added `IterableConfig.authCallbackTimeoutMs` (default `1000`) to tune the
21-
safety-net timeout for the auth callback latch.
20+
- Added `IterableConfig.authCallbackTimeoutMs` (default `6000`) to tune the
21+
safety-net timeout for the auth callback latch. The default is chosen to
22+
comfortably exceed typical mobile auth round-trips while staying well
23+
below the native 30s auth latch ceiling on both iOS and Android.
2224
- The auth callback gate is now event-driven: the native
2325
`handleAuthSuccessCalled` / `handleAuthFailureCalled` events resolve the
2426
latch immediately. The timer survives only as a fallback when no native

src/core/classes/Iterable.test.ts

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
IterableLogLevel,
2525
} from '../..';
2626
import { TestHelper } from '../../__tests__/TestHelper';
27+
import { IterableLogger } from './IterableLogger';
2728

2829
describe('Iterable', () => {
2930
beforeEach(() => {
@@ -335,7 +336,7 @@ describe('Iterable', () => {
335336
expect(config.urlHandler).toBe(undefined);
336337
expect(config.useInMemoryStorageForInApps).toBe(false);
337338
expect(config.androidWakeDelayMs).toBe(1000);
338-
expect(config.authCallbackTimeoutMs).toBe(1000);
339+
expect(config.authCallbackTimeoutMs).toBe(6000);
339340
const configDict = config.toDict();
340341
expect(configDict.allowedProtocols).toEqual([]);
341342
expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false);
@@ -353,7 +354,7 @@ describe('Iterable', () => {
353354
expect(configDict.urlHandlerPresent).toBe(false);
354355
expect(configDict.useInMemoryStorageForInApps).toBe(false);
355356
expect(configDict.androidWakeDelayMs).toBe(1000);
356-
expect(configDict.authCallbackTimeoutMs).toBe(1000);
357+
expect(configDict.authCallbackTimeoutMs).toBe(6000);
357358
});
358359

359360
it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => {
@@ -1801,9 +1802,12 @@ describe('Iterable', () => {
18011802
IterableEventName.handleAuthFailureCalled
18021803
);
18031804

1804-
// sets up config with authHandler that returns an AuthResponse
1805+
// sets up config with authHandler that returns an AuthResponse and a
1806+
// short safety-net timeout (default is 6000ms; we use 200ms here so
1807+
// the safety-net fires within the test window).
18051808
const config = new IterableConfig();
18061809
config.logReactNativeSdkCalls = false;
1810+
config.authCallbackTimeoutMs = 200;
18071811
const successCallback = jest.fn();
18081812
const failureCallback = jest.fn();
18091813
const authResponse = new IterableAuthResponse();
@@ -1819,7 +1823,7 @@ describe('Iterable', () => {
18191823
nativeEmitter.emit(IterableEventName.handleAuthCalled);
18201824

18211825
// THEN the token is forwarded and neither callback fires
1822-
return await TestHelper.delayed(1100, () => {
1826+
return await TestHelper.delayed(300, () => {
18231827
expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(
18241828
'timeout-token'
18251829
);
@@ -1905,5 +1909,94 @@ describe('Iterable', () => {
19051909
expect(failureCallback).not.toBeCalled();
19061910
});
19071911
});
1912+
1913+
it('should resolve both callbacks when two handleAuthCalled invocations overlap (SDK-520 regression)', async () => {
1914+
// Regression for the shared-latch-state bug: when a second
1915+
// handleAuthCalled event arrives while the first invocation's race is
1916+
// still pending, the original code wiped the first invocation's
1917+
// resolver, so the first successCallback was silently dropped. With
1918+
// per-invocation latch state, both invocations must resolve.
1919+
const nativeEmitter = new NativeEventEmitter();
1920+
nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled);
1921+
nativeEmitter.removeAllListeners(
1922+
IterableEventName.handleAuthSuccessCalled
1923+
);
1924+
nativeEmitter.removeAllListeners(
1925+
IterableEventName.handleAuthFailureCalled
1926+
);
1927+
1928+
const config = new IterableConfig();
1929+
config.logReactNativeSdkCalls = false;
1930+
config.authCallbackTimeoutMs = 2000;
1931+
1932+
const successCallback1 = jest.fn();
1933+
const failureCallback1 = jest.fn();
1934+
const authResponse1 = new IterableAuthResponse();
1935+
authResponse1.authToken = 'overlap-token-1';
1936+
authResponse1.successCallback = successCallback1;
1937+
authResponse1.failureCallback = failureCallback1;
1938+
1939+
const successCallback2 = jest.fn();
1940+
const failureCallback2 = jest.fn();
1941+
const authResponse2 = new IterableAuthResponse();
1942+
authResponse2.authToken = 'overlap-token-2';
1943+
authResponse2.successCallback = successCallback2;
1944+
authResponse2.failureCallback = failureCallback2;
1945+
1946+
// Use controllable promises so we can wire both latches before any
1947+
// native success event arrives. This is the realistic overlap shape:
1948+
// the SDK is awaiting native auth round-trips for two back-to-back
1949+
// handleAuthCalled events.
1950+
let resolveAuth1: (value: IterableAuthResponse) => void = () => {};
1951+
let resolveAuth2: (value: IterableAuthResponse) => void = () => {};
1952+
let callCount = 0;
1953+
config.authHandler = jest.fn(() => {
1954+
callCount += 1;
1955+
return new Promise<IterableAuthResponse>((resolve) => {
1956+
if (callCount === 1) {
1957+
resolveAuth1 = resolve;
1958+
} else {
1959+
resolveAuth2 = resolve;
1960+
}
1961+
});
1962+
});
1963+
1964+
const logSpy = jest.spyOn(IterableLogger, 'log');
1965+
1966+
Iterable.initialize('apiKey', config);
1967+
1968+
// WHEN two handleAuthCalled events fire back-to-back (both invocations
1969+
// are now pending and awaiting their authHandler promises).
1970+
nativeEmitter.emit(IterableEventName.handleAuthCalled);
1971+
nativeEmitter.emit(IterableEventName.handleAuthCalled);
1972+
1973+
// Resolve both authHandler promises. After the microtask queue
1974+
// flushes, both invocations' latches are wired and racing their
1975+
// safety-net timers. We defer the native success emissions to the
1976+
// next tick so the latch executors have run.
1977+
resolveAuth1(authResponse1);
1978+
resolveAuth2(authResponse2);
1979+
1980+
// Then the corresponding native success events arrive in FIFO order.
1981+
await new Promise((resolve) => setTimeout(resolve, 0));
1982+
nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled);
1983+
nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled);
1984+
1985+
// THEN both successCallbacks fire and no "No callback received"
1986+
// warning is logged.
1987+
return await TestHelper.delayed(100, () => {
1988+
expect(successCallback1).toBeCalled();
1989+
expect(successCallback2).toBeCalled();
1990+
expect(failureCallback1).not.toBeCalled();
1991+
expect(failureCallback2).not.toBeCalled();
1992+
const noCallbackCalls = logSpy.mock.calls.filter(
1993+
(args) =>
1994+
typeof args[0] === 'string' &&
1995+
args[0].includes('No callback received from native layer')
1996+
);
1997+
expect(noCallbackCalls).toHaveLength(0);
1998+
logSpy.mockRestore();
1999+
});
2000+
});
19082001
});
19092002
});

src/core/classes/Iterable.ts

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,6 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI);
2727

2828
const defaultConfig = new IterableConfig();
2929

30-
/**
31-
* Fallback safety-net timeout for the auth callback latch when no native
32-
* auth success/failure event arrives. Overridable via
33-
* {@link IterableConfig.authCallbackTimeoutMs}.
34-
*/
35-
const AUTH_CALLBACK_TIMEOUT_DEFAULT_MS = 1000;
36-
37-
/**
38-
* Default delay (ms) the SDK waits on Android before invoking the URL handler
39-
* so the host Activity can wake from the background. Overridable via
40-
* {@link IterableConfig.androidWakeDelayMs}.
41-
*/
42-
const ANDROID_WAKE_DELAY_DEFAULT_MS = 1000;
43-
4430
/**
4531
* Checks if the response is an IterableAuthResponse
4632
*/
@@ -1027,9 +1013,7 @@ export class Iterable {
10271013
// dispatching the URL to the handler. Without this delay the
10281014
// handler can race the activity lifecycle and drop the link on
10291015
// cold start. Tunable via IterableConfig.androidWakeDelayMs.
1030-
const wakeDelayMs =
1031-
Iterable.savedConfig.androidWakeDelayMs ??
1032-
ANDROID_WAKE_DELAY_DEFAULT_MS;
1016+
const wakeDelayMs = Iterable.savedConfig.androidWakeDelayMs;
10331017
if (wakeDelayMs > 0) {
10341018
setTimeout(() => {
10351019
callUrlHandler(Iterable.savedConfig, url, context);
@@ -1074,26 +1058,42 @@ export class Iterable {
10741058
| IterableAuthResponseResult
10751059
| typeof AUTH_RESULT_NO_CALLBACK;
10761060

1077-
// Event-driven auth latch. The native success/failure listeners
1078-
// resolve the latch when their event arrives; the safety-net timer
1079-
// resolves it only if no native event arrives within the configured
1080-
// window. The timer is a fallback — the latch resolves immediately
1081-
// when the native event fires.
1061+
// Per-invocation auth latch state. The native success/failure events
1062+
// resolve the latch when they arrive; the safety-net timer resolves it
1063+
// only if no native event arrives within the configured window. The
1064+
// timer is a fallback — the latch resolves immediately when the native
1065+
// event fires.
10821066
//
1083-
// `pendingAuthResult` buffers a native result that arrives before the
1067+
// `pendingResult` buffers a native result that arrives before the
10841068
// latch is created (e.g. the authHandler promise hasn't settled yet).
10851069
// It is consumed when the latch is created, so out-of-order events
10861070
// are not lost.
1087-
let authLatchResolver:
1088-
| ((result: IterableAuthResponseResult) => void)
1089-
| null = null;
1090-
let pendingAuthResult: IterableAuthResponseResult | null = null;
1071+
//
1072+
// State is scoped per `handleAuthCalled` invocation so overlapping
1073+
// invocations cannot wipe each other's resolver or buffered result
1074+
// (the bug with the original shared `authLatchResolver` /
1075+
// `pendingAuthResult` declarations). The bridge events carry no
1076+
// correlation id, so native success/failure events are routed to the
1077+
// oldest still-pending invocation in FIFO order — matching the
1078+
// original single-flight assumption that native processes auth
1079+
// requests in the order the SDK issues them.
1080+
type AuthInvocation = {
1081+
resolver:
1082+
| ((result: IterableAuthResponseResult) => void)
1083+
| null;
1084+
pendingResult: IterableAuthResponseResult | null;
1085+
};
1086+
const pendingAuthInvocations: AuthInvocation[] = [];
10911087

10921088
RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => {
1093-
// Reset per-invocation state so a stale buffered result from a
1094-
// previous invocation cannot bleed into this one.
1095-
authLatchResolver = null;
1096-
pendingAuthResult = null;
1089+
// Start a fresh per-invocation state object so a stale buffered
1090+
// result or resolver from a previous invocation cannot bleed into
1091+
// this one.
1092+
const invocation: AuthInvocation = {
1093+
resolver: null,
1094+
pendingResult: null,
1095+
};
1096+
pendingAuthInvocations.push(invocation);
10971097

10981098
// MOB-10423: Check if we can use chain operator (?.) here instead
10991099
// Asks frontend of the client/app to pass authToken
@@ -1109,23 +1109,27 @@ export class Iterable {
11091109
if (isIterableAuthResponse(promiseResult)) {
11101110
Iterable.authManager.passAlongAuthToken(promiseResult.authToken);
11111111

1112+
// If this invocation was already removed (e.g. a buffered
1113+
// native result already resolved it via another path), do not
1114+
// wire a latch for it.
1115+
if (!pendingAuthInvocations.includes(invocation)) {
1116+
return;
1117+
}
1118+
11121119
const nativeLatch = new Promise<IterableAuthResponseResult>(
11131120
(resolve) => {
1114-
if (pendingAuthResult !== null) {
1121+
if (invocation.pendingResult !== null) {
11151122
// A native event arrived before the latch was created;
11161123
// resolve immediately with the buffered result.
1117-
const buffered = pendingAuthResult;
1118-
pendingAuthResult = null;
1119-
resolve(buffered);
1124+
resolve(invocation.pendingResult);
1125+
invocation.pendingResult = null;
11201126
} else {
1121-
authLatchResolver = resolve;
1127+
invocation.resolver = resolve;
11221128
}
11231129
}
11241130
);
11251131

1126-
const timeoutMs =
1127-
Iterable.savedConfig.authCallbackTimeoutMs ??
1128-
AUTH_CALLBACK_TIMEOUT_DEFAULT_MS;
1132+
const timeoutMs = Iterable.savedConfig.authCallbackTimeoutMs;
11291133
const timeoutLatch = new Promise<AuthLatchResult>((resolve) => {
11301134
setTimeout(
11311135
() => resolve(AUTH_RESULT_NO_CALLBACK),
@@ -1135,10 +1139,19 @@ export class Iterable {
11351139

11361140
Promise.race<AuthLatchResult>([nativeLatch, timeoutLatch]).then(
11371141
(result) => {
1138-
// Clear the resolver so a late native event after the timeout
1139-
// is a no-op.
1140-
authLatchResolver = null;
1141-
pendingAuthResult = null;
1142+
// Clear this invocation's resolver so a late native event
1143+
// after the timeout is a no-op. Only touch this
1144+
// invocation's state — other invocations own their own.
1145+
invocation.resolver = null;
1146+
invocation.pendingResult = null;
1147+
// Defensive cleanup: if the native event did not remove
1148+
// this invocation from the queue (e.g. the safety-net
1149+
// timer won the race), remove it now so future native
1150+
// events route to the next pending invocation.
1151+
const index = pendingAuthInvocations.indexOf(invocation);
1152+
if (index !== -1) {
1153+
pendingAuthInvocations.splice(index, 1);
1154+
}
11421155
if (result === IterableAuthResponseResult.SUCCESS) {
11431156
promiseResult.successCallback?.();
11441157
} else if (result === IterableAuthResponseResult.FAILURE) {
@@ -1170,32 +1183,46 @@ export class Iterable {
11701183
RNEventEmitter.addListener(
11711184
IterableEventName.handleAuthSuccessCalled,
11721185
() => {
1186+
// Route to the oldest still-pending invocation (FIFO). The bridge
1187+
// events carry no correlation id; this matches the assumption
1188+
// that native processes auth requests in issuance order.
1189+
const invocation = pendingAuthInvocations[0];
1190+
if (!invocation) {
1191+
return;
1192+
}
11731193
// Resolve the pending auth latch immediately; the timer becomes a
11741194
// no-op for this invocation.
1175-
if (authLatchResolver) {
1176-
const resolve = authLatchResolver;
1177-
authLatchResolver = null;
1178-
pendingAuthResult = null;
1195+
if (invocation.resolver) {
1196+
const resolve = invocation.resolver;
1197+
invocation.resolver = null;
1198+
invocation.pendingResult = null;
1199+
// Remove synchronously so the next native event routes to the
1200+
// following pending invocation, not back to this one.
1201+
pendingAuthInvocations.shift();
11791202
resolve(IterableAuthResponseResult.SUCCESS);
11801203
} else {
11811204
// Latch not created yet — buffer the result for the latch.
1182-
pendingAuthResult = IterableAuthResponseResult.SUCCESS;
1205+
invocation.pendingResult = IterableAuthResponseResult.SUCCESS;
11831206
}
11841207
}
11851208
);
11861209
RNEventEmitter.addListener(
11871210
IterableEventName.handleAuthFailureCalled,
11881211
(authFailureResponse: IterableAuthFailure) => {
1189-
// Resolve the pending auth latch immediately; the timer becomes a
1190-
// no-op for this invocation.
1191-
if (authLatchResolver) {
1192-
const resolve = authLatchResolver;
1193-
authLatchResolver = null;
1194-
pendingAuthResult = null;
1195-
resolve(IterableAuthResponseResult.FAILURE);
1196-
} else {
1197-
// Latch not created yet — buffer the result for the latch.
1198-
pendingAuthResult = IterableAuthResponseResult.FAILURE;
1212+
const invocation = pendingAuthInvocations[0];
1213+
if (invocation) {
1214+
// Resolve the pending auth latch immediately; the timer becomes a
1215+
// no-op for this invocation.
1216+
if (invocation.resolver) {
1217+
const resolve = invocation.resolver;
1218+
invocation.resolver = null;
1219+
invocation.pendingResult = null;
1220+
pendingAuthInvocations.shift();
1221+
resolve(IterableAuthResponseResult.FAILURE);
1222+
} else {
1223+
// Latch not created yet — buffer the result for the latch.
1224+
invocation.pendingResult = IterableAuthResponseResult.FAILURE;
1225+
}
11991226
}
12001227

12011228
// Call the actual JWT error with `authFailure` object.

src/core/classes/IterableConfig.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -372,19 +372,24 @@ export class IterableConfig {
372372
* resolves immediately when the native event arrives.
373373
*
374374
* @remarks
375-
* Increase this value on networks or devices where native auth round-trips
376-
* are slow. Setting it too low can cause premature "no callback received"
377-
* warnings. Setting it too high increases perceived auth latency only when
378-
* the native layer fails to respond.
375+
* The default (`6000`) is chosen to comfortably exceed a typical mobile
376+
* auth round-trip (the native layer performs `passAlongAuthToken` plus a
377+
* full HTTP round-trip to the Iterable backend, which routinely exceeds
378+
* 1 s on real networks) while staying well below the native auth latch
379+
* ceiling of 30 s on both iOS and Android. Setting it below typical
380+
* round-trip latency causes the safety net to win the race and drop the
381+
* late native `successCallback` / `failureCallback`; setting it too high
382+
* increases perceived auth latency only when the native layer fails to
383+
* respond.
379384
*
380385
* @example
381386
* ```typescript
382387
* const config = new IterableConfig();
383-
* config.authCallbackTimeoutMs = 2000; // wait up to 2s for native auth events
388+
* config.authCallbackTimeoutMs = 10000; // allow up to 10s for slow networks
384389
* Iterable.initialize('<YOUR_API_KEY>', config);
385390
* ```
386391
*/
387-
authCallbackTimeoutMs = 1000;
392+
authCallbackTimeoutMs = 6000;
388393

389394
/**
390395
* Should the SDK enable and use embedded messaging?

0 commit comments

Comments
 (0)