Skip to content

Commit 36a1ee5

Browse files
committed
fix(SDK-520): drain auth FIFO queue on non-happy paths to prevent zombie invocations
- Add removeInvocation() in handleAuthCalled; call it in the string, null/undefined, unexpected-type, and .catch branches so rejected/non-IterableAuthResponse auth handlers no longer leave a zombie at queue head - Prevents the next invocation's native success/failure event from routing to a dead invocation and dropping its callback - Add regression test: handleAuthCalled -> reject -> handleAuthCalled -> resolve -> native success asserts second successCallback fires with no 'No callback received' warning
1 parent fe5a64d commit 36a1ee5

2 files changed

Lines changed: 100 additions & 1 deletion

File tree

src/core/classes/Iterable.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1998,5 +1998,86 @@ describe('Iterable', () => {
19981998
logSpy.mockRestore();
19991999
});
20002000
});
2001+
2002+
it('should not block the next callback when authHandler rejects (SDK-520 follow-up regression)', async () => {
2003+
// Regression for the zombie-invocation bug: when an authHandler
2004+
// rejects, the .catch path previously left the invocation at the head
2005+
// of pendingAuthInvocations. The late native event for the rejected
2006+
// invocation buffered into the zombie, so the next real invocation's
2007+
// native success event routed to the zombie and its successCallback
2008+
// was silently dropped. With removeInvocation() in the .catch path,
2009+
// the rejected invocation is dropped from the queue before native's
2010+
// late event arrives, so the next invocation's native event routes
2011+
// correctly.
2012+
const nativeEmitter = new NativeEventEmitter();
2013+
nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled);
2014+
nativeEmitter.removeAllListeners(
2015+
IterableEventName.handleAuthSuccessCalled
2016+
);
2017+
nativeEmitter.removeAllListeners(
2018+
IterableEventName.handleAuthFailureCalled
2019+
);
2020+
2021+
const config = new IterableConfig();
2022+
config.logReactNativeSdkCalls = false;
2023+
config.authCallbackTimeoutMs = 2000;
2024+
2025+
const successCallback2 = jest.fn();
2026+
const failureCallback2 = jest.fn();
2027+
const authResponse2 = new IterableAuthResponse();
2028+
authResponse2.authToken = 'retry-success-token';
2029+
authResponse2.successCallback = successCallback2;
2030+
authResponse2.failureCallback = failureCallback2;
2031+
2032+
// inv1 rejects (authHandler throws); inv2 resolves with an
2033+
// IterableAuthResponse. Controllable promises let us sequence the
2034+
// second resolve after the first rejection has settled.
2035+
let resolveAuth2: (value: IterableAuthResponse) => void = () => {};
2036+
let callCount = 0;
2037+
config.authHandler = jest.fn(() => {
2038+
callCount += 1;
2039+
if (callCount === 1) {
2040+
return Promise.reject(new Error('Auth failed (inv1)'));
2041+
}
2042+
return new Promise<IterableAuthResponse>((resolve) => {
2043+
resolveAuth2 = resolve;
2044+
});
2045+
});
2046+
2047+
const logSpy = jest.spyOn(IterableLogger, 'log');
2048+
2049+
Iterable.initialize('apiKey', config);
2050+
2051+
// WHEN the first handleAuthCalled fires and authHandler rejects.
2052+
// The .catch path must remove inv1 from the queue.
2053+
nativeEmitter.emit(IterableEventName.handleAuthCalled);
2054+
// Let the rejection settle (microtask queue flush).
2055+
await new Promise((resolve) => setTimeout(resolve, 0));
2056+
2057+
// WHEN a second handleAuthCalled fires and authHandler resolves with
2058+
// an IterableAuthResponse, wiring inv2's latch.
2059+
nativeEmitter.emit(IterableEventName.handleAuthCalled);
2060+
resolveAuth2(authResponse2);
2061+
await new Promise((resolve) => setTimeout(resolve, 0));
2062+
2063+
// THEN the native success event for inv2 routes to inv2 (not to a
2064+
// zombie at queue head) and inv2's successCallback fires.
2065+
nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled);
2066+
2067+
return await TestHelper.delayed(50, () => {
2068+
expect(successCallback2).toBeCalled();
2069+
expect(failureCallback2).not.toBeCalled();
2070+
// No "No callback received" warning: if inv1 had stayed at queue
2071+
// head, inv2's native event would have routed to the zombie and
2072+
// inv2's safety-net timer would have fired NO_CALLBACK.
2073+
const noCallbackCalls = logSpy.mock.calls.filter(
2074+
(args) =>
2075+
typeof args[0] === 'string' &&
2076+
args[0].includes('No callback received from native layer')
2077+
);
2078+
expect(noCallbackCalls).toHaveLength(0);
2079+
logSpy.mockRestore();
2080+
});
2081+
});
20012082
});
20022083
});

src/core/classes/Iterable.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,18 @@ export class Iterable {
10951095
};
10961096
pendingAuthInvocations.push(invocation);
10971097

1098+
// Drop this invocation from the FIFO queue. The bridge events carry
1099+
// no correlation id, so a late native event for this invocation may
1100+
// route to the new queue head — a pre-existing bridge limitation that
1101+
// is strictly better than leaving a zombie at head (which would
1102+
// deterministically block the next real callback).
1103+
const removeInvocation = () => {
1104+
const index = pendingAuthInvocations.indexOf(invocation);
1105+
if (index !== -1) {
1106+
pendingAuthInvocations.splice(index, 1);
1107+
}
1108+
};
1109+
10981110
// MOB-10423: Check if we can use chain operator (?.) here instead
10991111
// Asks frontend of the client/app to pass authToken
11001112
Iterable.savedConfig.authHandler!()
@@ -1167,17 +1179,23 @@ export class Iterable {
11671179
} else if (typeof promiseResult === 'string') {
11681180
// If promise only returns string
11691181
Iterable.authManager.passAlongAuthToken(promiseResult);
1182+
removeInvocation();
11701183
} else if (promiseResult === null || promiseResult === undefined) {
11711184
// Even though this will cause authentication to fail, we want to
11721185
// allow for this for JWT handling.
11731186
Iterable.authManager.passAlongAuthToken(promiseResult);
1187+
removeInvocation();
11741188
} else {
11751189
IterableLogger?.log(
11761190
'Unexpected promise returned. Auth token expects promise of String, null, undefined, or AuthResponse type.'
11771191
);
1192+
removeInvocation();
11781193
}
11791194
})
1180-
.catch((e) => IterableLogger?.log(e));
1195+
.catch((e) => {
1196+
IterableLogger?.log(e);
1197+
removeInvocation();
1198+
});
11811199
});
11821200

11831201
RNEventEmitter.addListener(

0 commit comments

Comments
 (0)