Skip to content

Commit fd1623f

Browse files
authored
Merge pull request Expensify#81811 from Krishna2323/krishna2323/issue/81781
[NO QA] Error: TaskQueue: Error with task : [Pusher] instance not found. Pusher.subscribe().
2 parents f068872 + fb14847 commit fd1623f

3 files changed

Lines changed: 155 additions & 4 deletions

File tree

src/libs/Pusher/index.native.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {PusherAuthorizerResult, PusherChannel} from '@pusher/pusher-websocket-react-native';
22
import {Pusher} from '@pusher/pusher-websocket-react-native';
3+
import * as Sentry from '@sentry/react-native';
34
import isObject from 'lodash/isObject';
45
import {InteractionManager} from 'react-native';
56
import Onyx from 'react-native-onyx';
@@ -211,8 +212,22 @@ function subscribe<EventName extends PusherEventName>(
211212
InteractionManager.runAfterInteractions(() => {
212213
// We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
213214
if (!socket) {
214-
throw new Error(`[Pusher] instance not found. Pusher.subscribe()
215-
most likely has been called before Pusher.init()`);
215+
const error = new Error('[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()');
216+
217+
if (__DEV__) {
218+
throw error;
219+
}
220+
221+
// In production, report to Sentry without crashing the app.
222+
// This can happen when disconnect() is called (e.g. during the "Upgrade Required"
223+
// teardown) before this deferred InteractionManager callback runs.
224+
Sentry.captureException(error, {
225+
tags: {source: 'Pusher.subscribe'},
226+
extra: {channelName, eventName},
227+
});
228+
Log.info('[Pusher] Socket disconnected before subscribe could complete, skipping subscription', false, {channelName, eventName});
229+
resolve();
230+
return;
216231
}
217232

218233
Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName});

src/libs/Pusher/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Sentry from '@sentry/react-native';
12
import isObject from 'lodash/isObject';
23
import type {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption';
34
import Pusher from 'pusher-js/with-encryption';
@@ -206,8 +207,22 @@ function subscribe<EventName extends PusherEventName>(
206207
InteractionManager.runAfterInteractions(() => {
207208
// We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
208209
if (!socket) {
209-
throw new Error(`[Pusher] instance not found. Pusher.subscribe()
210-
most likely has been called before Pusher.init()`);
210+
const error = new Error('[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()');
211+
212+
if (__DEV__) {
213+
throw error;
214+
}
215+
216+
// In production, report to Sentry without crashing the app.
217+
// This can happen when disconnect() is called (e.g. during the "Upgrade Required"
218+
// teardown) before this deferred InteractionManager callback runs.
219+
Sentry.captureException(error, {
220+
tags: {source: 'Pusher.subscribe'},
221+
extra: {channelName, eventName},
222+
});
223+
Log.info('[Pusher] Socket disconnected before subscribe could complete, skipping subscription', false, {channelName, eventName});
224+
resolve();
225+
return;
211226
}
212227

213228
Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName});

tests/unit/PusherSubscribeTest.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Log from '@libs/Log';
2+
import Pusher from '@libs/Pusher';
3+
import CONFIG from '@src/CONFIG';
4+
import PusherConnectionManager from '@src/libs/PusherConnectionManager';
5+
6+
/**
7+
* Tests for Pusher.subscribe() graceful handling when socket is disconnected
8+
* before the deferred subscription callback runs.
9+
*
10+
* This covers the race condition where:
11+
* 1. Pusher.init() is called and connects
12+
* 2. Pusher.subscribe() is called, which defers work via InteractionManager
13+
* 3. Pusher.disconnect() is called (e.g. during "Upgrade Required" teardown)
14+
* 4. The deferred callback finally runs and finds socket === null
15+
*
16+
* Previously, this threw an unhandled error that crashed the app in production.
17+
* Now it reports to Sentry via captureException without crashing.
18+
*/
19+
20+
// Store the original __DEV__ value so we can restore it after tests
21+
// eslint-disable-next-line no-underscore-dangle
22+
const originalDev = __DEV__;
23+
24+
async function initPusher() {
25+
PusherConnectionManager.init();
26+
Pusher.init({
27+
appKey: CONFIG.PUSHER.APP_KEY,
28+
cluster: CONFIG.PUSHER.CLUSTER,
29+
authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`,
30+
});
31+
32+
// Flush microtasks so initPromise resolves.
33+
// Pusher.init() resolves via promise chains (socket.getSocketId().then → resolveInitPromise)
34+
// which require microtask flushing before initPromise is actually resolved.
35+
await jest.runAllTimersAsync();
36+
}
37+
38+
describe('Pusher.subscribe', () => {
39+
beforeEach(() => {
40+
jest.spyOn(Pusher, 'isSubscribed').mockReturnValue(false);
41+
jest.spyOn(Pusher, 'isAlreadySubscribing').mockReturnValue(false);
42+
});
43+
44+
afterEach(() => {
45+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-underscore-dangle
46+
(global as Record<string, unknown>).__DEV__ = originalDev;
47+
Pusher.disconnect();
48+
jest.restoreAllMocks();
49+
});
50+
51+
it('should resolve gracefully when socket is disconnected before subscribe callback runs', async () => {
52+
// Simulate production environment so we hit the Sentry.captureException path
53+
// instead of the __DEV__ throw path
54+
// eslint-disable-next-line no-underscore-dangle
55+
(global as Record<string, unknown>).__DEV__ = false;
56+
57+
// 1. Initialize Pusher and wait for initPromise to resolve
58+
await initPusher();
59+
60+
// 2. Start subscribe — captures the already-resolved initPromise
61+
// InteractionManager.runAfterInteractions callback is queued but hasn't fired yet
62+
const subscribePromise = Pusher.subscribe('private-user-123', 'multipleEvents');
63+
64+
// 3. Disconnect BEFORE the InteractionManager callback runs (sets socket = null)
65+
// This simulates the race condition during "Upgrade Required" teardown
66+
Pusher.disconnect();
67+
68+
// 4. Flush timers and microtasks so the InteractionManager callback fires
69+
await jest.runAllTimersAsync();
70+
71+
// 5. Subscribe should NOT throw — it should resolve gracefully
72+
await expect(subscribePromise).resolves.toBeUndefined();
73+
});
74+
75+
it('should log a message when skipping subscription due to disconnected socket', async () => {
76+
// Simulate production environment
77+
// eslint-disable-next-line no-underscore-dangle
78+
(global as Record<string, unknown>).__DEV__ = false;
79+
80+
const logSpy = jest.spyOn(Log, 'info');
81+
82+
await initPusher();
83+
84+
const subscribePromise = Pusher.subscribe('private-user-456', 'multipleEvents');
85+
Pusher.disconnect();
86+
87+
await jest.runAllTimersAsync();
88+
await subscribePromise;
89+
90+
expect(logSpy).toHaveBeenCalledWith('[Pusher] Socket disconnected before subscribe could complete, skipping subscription', false, {
91+
channelName: 'private-user-456',
92+
eventName: 'multipleEvents',
93+
});
94+
});
95+
96+
it('should throw in dev when socket is disconnected before subscribe callback runs', async () => {
97+
// Ensure __DEV__ is true (the default in Jest)
98+
// eslint-disable-next-line no-underscore-dangle
99+
(global as Record<string, unknown>).__DEV__ = true;
100+
101+
await initPusher();
102+
103+
const subscribePromise = Pusher.subscribe('private-user-dev', 'multipleEvents');
104+
Pusher.disconnect();
105+
106+
await jest.runAllTimersAsync();
107+
108+
await expect(subscribePromise).rejects.toThrow('[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()');
109+
});
110+
111+
it('should subscribe successfully when socket is connected', async () => {
112+
await initPusher();
113+
114+
const subscribePromise = Pusher.subscribe('private-user-789', 'multipleEvents');
115+
116+
// Flush so InteractionManager callback fires and subscription completes
117+
await jest.runAllTimersAsync();
118+
119+
await expect(subscribePromise).resolves.toBeUndefined();
120+
});
121+
});

0 commit comments

Comments
 (0)