@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. 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
+ });
@@ -423,7 +423,7 @@ export class StreamClient {
423
423
  return this.connectionIdPromiseSafe?.();
424
424
  }
425
425
 
426
- get isConnectionIsPromisePending() {
426
+ get isConnectionIdPromisePending() {
427
427
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
428
428
  }
429
429