@stream-io/video-client 1.49.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +89 -22
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { StreamClient } from '../client';
|
|
3
|
+
import { StableWSConnection } from '../connection';
|
|
4
|
+
import type { WSConnectionError } from '../types';
|
|
5
|
+
|
|
6
|
+
class StuckWebSocket {
|
|
7
|
+
static CONNECTING = 0;
|
|
8
|
+
static OPEN = 1;
|
|
9
|
+
static CLOSING = 2;
|
|
10
|
+
static CLOSED = 3;
|
|
11
|
+
static instances: StuckWebSocket[] = [];
|
|
12
|
+
|
|
13
|
+
// instance-level constants so consumers can read e.g. ws.CLOSED
|
|
14
|
+
CONNECTING = StuckWebSocket.CONNECTING;
|
|
15
|
+
OPEN = StuckWebSocket.OPEN;
|
|
16
|
+
CLOSING = StuckWebSocket.CLOSING;
|
|
17
|
+
CLOSED = StuckWebSocket.CLOSED;
|
|
18
|
+
|
|
19
|
+
readyState = StuckWebSocket.CONNECTING;
|
|
20
|
+
url: string;
|
|
21
|
+
onopen: ((ev?: unknown) => unknown) | null = null;
|
|
22
|
+
onclose: ((ev?: unknown) => unknown) | null = null;
|
|
23
|
+
onerror: ((ev?: unknown) => unknown) | null = null;
|
|
24
|
+
onmessage: ((ev?: unknown) => unknown) | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(url: string | URL) {
|
|
27
|
+
this.url = url.toString();
|
|
28
|
+
StuckWebSocket.instances.push(this);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
close = () => {
|
|
32
|
+
this.readyState = StuckWebSocket.CLOSED;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
send = () => {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A drivable mock that lets the test fire onopen / onmessage / onclose
|
|
39
|
+
// at chosen points so we can observe behavior between handshake events.
|
|
40
|
+
class ManualWebSocket {
|
|
41
|
+
static CONNECTING = 0;
|
|
42
|
+
static OPEN = 1;
|
|
43
|
+
static CLOSING = 2;
|
|
44
|
+
static CLOSED = 3;
|
|
45
|
+
static instances: ManualWebSocket[] = [];
|
|
46
|
+
|
|
47
|
+
CONNECTING = ManualWebSocket.CONNECTING;
|
|
48
|
+
OPEN = ManualWebSocket.OPEN;
|
|
49
|
+
CLOSING = ManualWebSocket.CLOSING;
|
|
50
|
+
CLOSED = ManualWebSocket.CLOSED;
|
|
51
|
+
|
|
52
|
+
readyState = ManualWebSocket.CONNECTING;
|
|
53
|
+
url: string;
|
|
54
|
+
onopen: ((ev?: unknown) => unknown) | null = null;
|
|
55
|
+
onclose: ((ev?: unknown) => unknown) | null = null;
|
|
56
|
+
onerror: ((ev?: unknown) => unknown) | null = null;
|
|
57
|
+
onmessage: ((ev?: unknown) => unknown) | null = null;
|
|
58
|
+
sentMessages: string[] = [];
|
|
59
|
+
|
|
60
|
+
constructor(url: string | URL) {
|
|
61
|
+
this.url = url.toString();
|
|
62
|
+
ManualWebSocket.instances.push(this);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fireOpen = () => {
|
|
66
|
+
this.readyState = ManualWebSocket.OPEN;
|
|
67
|
+
this.onopen?.({});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
fireConnectionOk = (connectionId: string) => {
|
|
71
|
+
this.onmessage?.({
|
|
72
|
+
data: JSON.stringify({
|
|
73
|
+
type: 'connection.ok',
|
|
74
|
+
connection_id: connectionId,
|
|
75
|
+
me: { id: 'test-user' },
|
|
76
|
+
}),
|
|
77
|
+
} as MessageEvent);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
close = () => {
|
|
81
|
+
this.readyState = ManualWebSocket.CLOSED;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
send = (data: string) => {
|
|
85
|
+
this.sentMessages.push(data);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const buildClient = () => {
|
|
90
|
+
const client = new StreamClient('test-key', {
|
|
91
|
+
browser: false,
|
|
92
|
+
defaultWsTimeout: 5000,
|
|
93
|
+
WebSocketImpl: StuckWebSocket as unknown as typeof WebSocket,
|
|
94
|
+
timeout: 1000,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
|
|
98
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
|
|
99
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
100
|
+
|
|
101
|
+
client._setUser({ id: 'test-user' });
|
|
102
|
+
client.userID = 'test-user';
|
|
103
|
+
client.clientID = 'test-user--abcdef';
|
|
104
|
+
|
|
105
|
+
// matches what StreamClient.openConnection does before kicking off connect()
|
|
106
|
+
client._setupConnectionIdPromise();
|
|
107
|
+
|
|
108
|
+
return client;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
describe('StableWSConnection - silent handshake hang', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
StuckWebSocket.instances = [];
|
|
114
|
+
ManualWebSocket.instances = [];
|
|
115
|
+
vi.useFakeTimers();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
vi.useRealTimers();
|
|
120
|
+
vi.restoreAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects in-flight connectionIdPromise within defaultWsTimeout when WS upgrade silently stalls', async () => {
|
|
124
|
+
const client = buildClient();
|
|
125
|
+
|
|
126
|
+
// capture the promise that doAxiosRequest (e.g. from Call.join) would
|
|
127
|
+
// already be awaiting before _connect runs to completion
|
|
128
|
+
const originalConnectionIdPromise = client.connectionIdPromise!;
|
|
129
|
+
expect(originalConnectionIdPromise).toBeDefined();
|
|
130
|
+
|
|
131
|
+
// track settlement deterministically. If the orphaning bug regresses,
|
|
132
|
+
// these stay false and the test fails via assertion (not a vitest
|
|
133
|
+
// test-timeout, which would only signal "hang somewhere").
|
|
134
|
+
let didResolve = false;
|
|
135
|
+
let rejectionError: WSConnectionError | undefined;
|
|
136
|
+
originalConnectionIdPromise.then(
|
|
137
|
+
() => {
|
|
138
|
+
didResolve = true;
|
|
139
|
+
},
|
|
140
|
+
(error: WSConnectionError) => {
|
|
141
|
+
rejectionError = error;
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const wsConnection = new StableWSConnection(client);
|
|
146
|
+
client.wsConnection = wsConnection;
|
|
147
|
+
const connectAttempt = wsConnection.connect(5000);
|
|
148
|
+
// attach a no-op rejection handler so vitest does not surface it as
|
|
149
|
+
// unhandled while we orchestrate fake timers; we still assert below.
|
|
150
|
+
const connectAttemptOutcome = connectAttempt.then(
|
|
151
|
+
() => ({ kind: 'resolved' as const }),
|
|
152
|
+
(error: WSConnectionError) => ({
|
|
153
|
+
kind: 'rejected' as const,
|
|
154
|
+
error,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// let the token mock resolve and the WS get instantiated
|
|
159
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
160
|
+
expect(StuckWebSocket.instances.length).toBe(1);
|
|
161
|
+
expect(StuckWebSocket.instances[0].readyState).toBe(
|
|
162
|
+
StuckWebSocket.CONNECTING,
|
|
163
|
+
);
|
|
164
|
+
// before the watchdog fires, the original promise must still be pending
|
|
165
|
+
expect(didResolve).toBe(false);
|
|
166
|
+
expect(rejectionError).toBeUndefined();
|
|
167
|
+
|
|
168
|
+
// trip the handshake watchdog
|
|
169
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
170
|
+
|
|
171
|
+
expect(didResolve).toBe(false);
|
|
172
|
+
expect(rejectionError).toBeInstanceOf(Error);
|
|
173
|
+
expect(rejectionError?.isWSFailure).toBe(true);
|
|
174
|
+
expect(rejectionError?.message).toMatch(/WS handshake timed out/);
|
|
175
|
+
|
|
176
|
+
// half-open WS should have been torn down by the catch block
|
|
177
|
+
expect(StuckWebSocket.instances[0].readyState).toBe(StuckWebSocket.CLOSED);
|
|
178
|
+
|
|
179
|
+
// isConnecting must be cleared so a subsequent reconnect can proceed
|
|
180
|
+
expect(wsConnection.isConnecting).toBe(false);
|
|
181
|
+
|
|
182
|
+
// and the connectionIdPromise must NOT have been replaced with a fresh
|
|
183
|
+
// pending one in the catch: it stays rejected so any awaiter captured
|
|
184
|
+
// between the catch and the _reconnect's retry interval fails fast
|
|
185
|
+
// instead of silently capturing a never-settling P2. _reconnect's
|
|
186
|
+
// entry guard (in _connect) will recreate it on the next attempt.
|
|
187
|
+
expect(client.isConnectionIdPromisePending).toBe(false);
|
|
188
|
+
|
|
189
|
+
// drain the outer connect()'s _waitForHealthy(5000) and assert it
|
|
190
|
+
// bubbles up an isWSFailure rejection (rather than hanging forever)
|
|
191
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
192
|
+
const outcome = await connectAttemptOutcome;
|
|
193
|
+
expect(outcome.kind).toBe('rejected');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('does not schedule a reconnect (and leaves connectionIdPromise rejected) on a permanent, non-WS failure', async () => {
|
|
197
|
+
const client = new StreamClient('test-key', {
|
|
198
|
+
browser: false,
|
|
199
|
+
defaultWsTimeout: 5000,
|
|
200
|
+
WebSocketImpl: StuckWebSocket as unknown as typeof WebSocket,
|
|
201
|
+
timeout: 1000,
|
|
202
|
+
});
|
|
203
|
+
// tokenReady rejects to push us into loadToken; loadToken then throws
|
|
204
|
+
// to drive _connect into its catch with an error that has no
|
|
205
|
+
// isWSFailure flag (the same shape as a permanent server reject).
|
|
206
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockRejectedValue(
|
|
207
|
+
new Error('token provider failed previously'),
|
|
208
|
+
);
|
|
209
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockRejectedValue(
|
|
210
|
+
new Error('permanent token error'),
|
|
211
|
+
);
|
|
212
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
213
|
+
|
|
214
|
+
client._setUser({ id: 'test-user' });
|
|
215
|
+
client.userID = 'test-user';
|
|
216
|
+
client.clientID = 'test-user--abcdef';
|
|
217
|
+
client._setupConnectionIdPromise();
|
|
218
|
+
|
|
219
|
+
const originalConnectionIdPromise = client.connectionIdPromise!;
|
|
220
|
+
let didResolve = false;
|
|
221
|
+
let rejectionError: Error | undefined;
|
|
222
|
+
originalConnectionIdPromise.then(
|
|
223
|
+
() => {
|
|
224
|
+
didResolve = true;
|
|
225
|
+
},
|
|
226
|
+
(error: Error) => {
|
|
227
|
+
rejectionError = error;
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const wsConnection = new StableWSConnection(client);
|
|
232
|
+
// spy on _reconnect to assert directly that no retry chain is
|
|
233
|
+
// launched on permanent failures
|
|
234
|
+
const reconnectSpy = vi.spyOn(wsConnection, '_reconnect');
|
|
235
|
+
client.wsConnection = wsConnection;
|
|
236
|
+
const connectAttempt = wsConnection.connect(5000);
|
|
237
|
+
const connectAttemptOutcome = connectAttempt.then(
|
|
238
|
+
() => ({ kind: 'resolved' as const }),
|
|
239
|
+
(error: Error) => ({ kind: 'rejected' as const, error }),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// let the token mock rejections propagate through _connect's catch
|
|
243
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
244
|
+
|
|
245
|
+
expect(didResolve).toBe(false);
|
|
246
|
+
expect(rejectionError).toBeInstanceOf(Error);
|
|
247
|
+
expect(rejectionError?.message).toMatch(/permanent token error/);
|
|
248
|
+
|
|
249
|
+
// Finding #2: catch must NOT recreate connectionIdPromise as a fresh
|
|
250
|
+
// pending one for permanent (non-isWSFailure) errors. Otherwise, a
|
|
251
|
+
// doAxiosRequest issued in this window would capture a P that nothing
|
|
252
|
+
// ever settles and would hang indefinitely.
|
|
253
|
+
expect(client.isConnectionIdPromisePending).toBe(false);
|
|
254
|
+
|
|
255
|
+
// and crucially, no reconnect chain was launched - the catch's
|
|
256
|
+
// _reconnect() call is gated on err.isWSFailure, which is absent here.
|
|
257
|
+
// drain a generous slice of fake time to be sure no retry sneaks in.
|
|
258
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
259
|
+
expect(reconnectSpy).not.toHaveBeenCalled();
|
|
260
|
+
|
|
261
|
+
const outcome = await connectAttemptOutcome;
|
|
262
|
+
expect(outcome.kind).toBe('rejected');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not write resolveConnectionId when disconnect runs after the handshake completes', async () => {
|
|
266
|
+
const client = new StreamClient('test-key', {
|
|
267
|
+
browser: false,
|
|
268
|
+
defaultWsTimeout: 5000,
|
|
269
|
+
WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
|
|
270
|
+
timeout: 1000,
|
|
271
|
+
});
|
|
272
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
|
|
273
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
|
|
274
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
275
|
+
|
|
276
|
+
client._setUser({ id: 'test-user' });
|
|
277
|
+
client.userID = 'test-user';
|
|
278
|
+
client.clientID = 'test-user--abcdef';
|
|
279
|
+
client._setupConnectionIdPromise();
|
|
280
|
+
|
|
281
|
+
// observe whether resolveConnectionId is called by wrapping it
|
|
282
|
+
let resolveConnectionIdCalled = false;
|
|
283
|
+
const originalResolve = client.resolveConnectionId;
|
|
284
|
+
client.resolveConnectionId = (...args: unknown[]) => {
|
|
285
|
+
resolveConnectionIdCalled = true;
|
|
286
|
+
return (originalResolve as (...a: unknown[]) => unknown)?.(...args);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const wsConnection = new StableWSConnection(client);
|
|
290
|
+
client.wsConnection = wsConnection;
|
|
291
|
+
const connectAttempt = wsConnection.connect(5000);
|
|
292
|
+
const connectAttemptOutcome = connectAttempt.then(
|
|
293
|
+
() => ({ kind: 'resolved' as const }),
|
|
294
|
+
(error: Error) => ({ kind: 'rejected' as const, error }),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// let the token resolve and the WS get created
|
|
298
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
299
|
+
const ws = ManualWebSocket.instances.at(-1)!;
|
|
300
|
+
expect(ws).toBeDefined();
|
|
301
|
+
|
|
302
|
+
// simulate a successful handshake: open, then connection.ok
|
|
303
|
+
ws.fireOpen();
|
|
304
|
+
ws.fireConnectionOk('stale-conn-id');
|
|
305
|
+
|
|
306
|
+
// SYNCHRONOUSLY mark the connection as disconnected (as
|
|
307
|
+
// closeConnection() / disconnectUser() would) BEFORE the await
|
|
308
|
+
// Promise.race continuation in _connect runs. The post-handshake
|
|
309
|
+
// isDisconnected guard in _connect must then short-circuit instead
|
|
310
|
+
// of writing stale connection_id into the client's resolver.
|
|
311
|
+
wsConnection.disconnect();
|
|
312
|
+
|
|
313
|
+
// flush microtasks so the await Promise.race in _connect resumes
|
|
314
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
315
|
+
|
|
316
|
+
// Finding #1: resolveConnectionId must NOT have been called and
|
|
317
|
+
// wsConnection.connectionID must remain unset.
|
|
318
|
+
expect(resolveConnectionIdCalled).toBe(false);
|
|
319
|
+
expect(wsConnection.connectionID).toBeUndefined();
|
|
320
|
+
|
|
321
|
+
// and the new ws must be torn down by the guard's destroy call
|
|
322
|
+
expect(ws.readyState).toBe(ManualWebSocket.CLOSED);
|
|
323
|
+
|
|
324
|
+
// The post-handshake guard must surface the abort to the caller of
|
|
325
|
+
// connect() instead of silently returning. Otherwise _waitForHealthy
|
|
326
|
+
// would observe the already-resolved connectionOpen and resolve with
|
|
327
|
+
// a ConnectedEvent for a torn-down connection.
|
|
328
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
329
|
+
const outcome = await connectAttemptOutcome;
|
|
330
|
+
expect(outcome.kind).toBe('rejected');
|
|
331
|
+
if (outcome.kind === 'rejected') {
|
|
332
|
+
expect(outcome.error.message).toMatch(
|
|
333
|
+
/disconnect\(\) ran while connecting/,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('rejects the captured client.connectionIdPromise when disconnect aborts a handshake (no reopen)', async () => {
|
|
339
|
+
const client = new StreamClient('test-key', {
|
|
340
|
+
browser: false,
|
|
341
|
+
defaultWsTimeout: 5000,
|
|
342
|
+
WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
|
|
343
|
+
timeout: 1000,
|
|
344
|
+
});
|
|
345
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
|
|
346
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
|
|
347
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
348
|
+
|
|
349
|
+
client._setUser({ id: 'test-user' });
|
|
350
|
+
client.userID = 'test-user';
|
|
351
|
+
client.clientID = 'test-user--abcdef';
|
|
352
|
+
client._setupConnectionIdPromise();
|
|
353
|
+
|
|
354
|
+
// capture the connection-id promise BEFORE the handshake races against
|
|
355
|
+
// disconnect. doAxiosRequest awaits this same promise before sending
|
|
356
|
+
// non-public REST calls, so if it never settles those callers hang
|
|
357
|
+
// forever (the regression Codex flagged).
|
|
358
|
+
const capturedPromise = client.connectionIdPromise!;
|
|
359
|
+
expect(capturedPromise).toBeDefined();
|
|
360
|
+
let capturedResolved = false;
|
|
361
|
+
let capturedRejected: Error | undefined;
|
|
362
|
+
capturedPromise.then(
|
|
363
|
+
() => {
|
|
364
|
+
capturedResolved = true;
|
|
365
|
+
},
|
|
366
|
+
(error: Error) => {
|
|
367
|
+
capturedRejected = error;
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const wsConnection = new StableWSConnection(client);
|
|
372
|
+
client.wsConnection = wsConnection;
|
|
373
|
+
const connectAttempt = wsConnection.connect(5000);
|
|
374
|
+
const connectAttemptOutcome = connectAttempt.then(
|
|
375
|
+
() => ({ kind: 'resolved' as const }),
|
|
376
|
+
(error: Error) => ({ kind: 'rejected' as const, error }),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
380
|
+
const ws = ManualWebSocket.instances.at(-1)!;
|
|
381
|
+
|
|
382
|
+
// successful handshake then synchronous disconnect (closeConnection
|
|
383
|
+
// path - does NOT touch client.connectionIdPromise)
|
|
384
|
+
ws.fireOpen();
|
|
385
|
+
ws.fireConnectionOk('stale-conn-id');
|
|
386
|
+
wsConnection.disconnect();
|
|
387
|
+
|
|
388
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
389
|
+
|
|
390
|
+
// The captured connection-id promise must be rejected (not stuck
|
|
391
|
+
// pending), so any in-flight doAxiosRequest fails fast.
|
|
392
|
+
expect(capturedResolved).toBe(false);
|
|
393
|
+
expect(capturedRejected).toBeInstanceOf(Error);
|
|
394
|
+
expect(capturedRejected?.message).toMatch(
|
|
395
|
+
/disconnect\(\) ran while connecting/,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// drain outer connect() bookkeeping
|
|
399
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
400
|
+
await connectAttemptOutcome;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('rejects only the original promise when openConnection rotates resolvers mid-abort', async () => {
|
|
404
|
+
const client = new StreamClient('test-key', {
|
|
405
|
+
browser: false,
|
|
406
|
+
defaultWsTimeout: 5000,
|
|
407
|
+
WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
|
|
408
|
+
timeout: 1000,
|
|
409
|
+
});
|
|
410
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
|
|
411
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
|
|
412
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
413
|
+
|
|
414
|
+
client._setUser({ id: 'test-user' });
|
|
415
|
+
client.userID = 'test-user';
|
|
416
|
+
client.clientID = 'test-user--abcdef';
|
|
417
|
+
client._setupConnectionIdPromise();
|
|
418
|
+
|
|
419
|
+
// P1: the promise the in-flight _connect attempt is supposed to settle.
|
|
420
|
+
const promiseP1 = client.connectionIdPromise!;
|
|
421
|
+
let p1Resolved = false;
|
|
422
|
+
let p1Rejected: Error | undefined;
|
|
423
|
+
promiseP1.then(
|
|
424
|
+
() => {
|
|
425
|
+
p1Resolved = true;
|
|
426
|
+
},
|
|
427
|
+
(error: Error) => {
|
|
428
|
+
p1Rejected = error;
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const wsConnection = new StableWSConnection(client);
|
|
433
|
+
client.wsConnection = wsConnection;
|
|
434
|
+
const connectAttempt = wsConnection.connect(5000);
|
|
435
|
+
const connectAttemptOutcome = connectAttempt.then(
|
|
436
|
+
() => ({ kind: 'resolved' as const }),
|
|
437
|
+
(error: Error) => ({ kind: 'rejected' as const, error }),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
441
|
+
const ws = ManualWebSocket.instances.at(-1)!;
|
|
442
|
+
|
|
443
|
+
// synchronous chain: handshake completes, disconnect runs, and then
|
|
444
|
+
// a concurrent openConnection() rotates the client-level resolvers
|
|
445
|
+
// to a fresh P2 - all BEFORE the _connect catch runs.
|
|
446
|
+
ws.fireOpen();
|
|
447
|
+
ws.fireConnectionOk('stale-conn-id');
|
|
448
|
+
wsConnection.disconnect();
|
|
449
|
+
client._setupConnectionIdPromise();
|
|
450
|
+
|
|
451
|
+
// P2: the rotated promise that the (hypothetical) follow-up
|
|
452
|
+
// openConnection would own. The stale attempt's catch must NOT
|
|
453
|
+
// settle this one.
|
|
454
|
+
const promiseP2 = client.connectionIdPromise!;
|
|
455
|
+
let p2Resolved = false;
|
|
456
|
+
let p2Rejected: Error | undefined;
|
|
457
|
+
promiseP2.then(
|
|
458
|
+
() => {
|
|
459
|
+
p2Resolved = true;
|
|
460
|
+
},
|
|
461
|
+
(error: Error) => {
|
|
462
|
+
p2Rejected = error;
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// microtask flush -> _connect catch runs -> ownRejectConnectionId
|
|
467
|
+
// (closure captured BEFORE rotation) settles P1. P2 is untouched.
|
|
468
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
469
|
+
|
|
470
|
+
expect(p1Resolved).toBe(false);
|
|
471
|
+
expect(p1Rejected).toBeInstanceOf(Error);
|
|
472
|
+
expect(p1Rejected?.message).toMatch(/disconnect\(\) ran while connecting/);
|
|
473
|
+
|
|
474
|
+
// P2 must still be pending - rotation isolation is the whole point
|
|
475
|
+
// of capturing the reject closure per attempt.
|
|
476
|
+
expect(p2Resolved).toBe(false);
|
|
477
|
+
expect(p2Rejected).toBeUndefined();
|
|
478
|
+
|
|
479
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
480
|
+
await connectAttemptOutcome;
|
|
481
|
+
});
|
|
482
|
+
});
|