livekit-client 2.22.0 → 2.22.1
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/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +187 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1764 -121
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +187 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts +25 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logger.d.ts +2 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/options.d.ts +22 -0
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +2 -1
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +7 -2
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +8 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +1 -1
- package/dist/src/room/statsSummary.d.ts +13 -0
- package/dist/src/room/statsSummary.d.ts.map +1 -0
- package/dist/src/room/token-source/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts +10 -0
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/utils/machineInspector.d.ts +54 -0
- package/dist/src/utils/machineInspector.d.ts.map +1 -0
- package/dist/ts4.2/api/SignalClient.d.ts +25 -1
- package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/ts4.2/index.d.ts +3 -2
- package/dist/ts4.2/logger.d.ts +2 -1
- package/dist/ts4.2/options.d.ts +22 -0
- package/dist/ts4.2/room/PCTransport.d.ts +2 -1
- package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
- package/dist/ts4.2/room/Room.d.ts +8 -1
- package/dist/ts4.2/room/events.d.ts +1 -1
- package/dist/ts4.2/room/statsSummary.d.ts +13 -0
- package/dist/ts4.2/room/track/Track.d.ts +10 -0
- package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
- package/package.json +7 -1
- package/src/api/SignalClient.test.ts +320 -8
- package/src/api/SignalClient.ts +260 -82
- package/src/api/SignalClientStateMachine.test.ts +472 -0
- package/src/api/SignalClientStateMachine.ts +180 -0
- package/src/api/WebSocketStream.ts +19 -3
- package/src/api/utils.test.ts +20 -1
- package/src/api/utils.ts +5 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +1 -0
- package/src/options.ts +24 -0
- package/src/room/PCTransport.ts +2 -1
- package/src/room/RTCEngine.ts +16 -7
- package/src/room/Room.ts +70 -5
- package/src/room/events.ts +1 -1
- package/src/room/statsSummary.ts +187 -0
- package/src/room/token-source/test-tokens.ts +20 -0
- package/src/room/token-source/utils.test.ts +27 -0
- package/src/room/token-source/utils.ts +12 -5
- package/src/room/track/LocalAudioTrack.ts +9 -3
- package/src/room/track/Track.ts +27 -0
- package/src/room/utils.test.ts +24 -1
- package/src/room/utils.ts +1 -1
- package/src/utils/machineInspector.ts +90 -0
package/src/api/SignalClient.ts
CHANGED
|
@@ -57,12 +57,19 @@ import {
|
|
|
57
57
|
import log, { LoggerNames, getLogger } from '../logger';
|
|
58
58
|
import type { DataTrackHandle } from '../room/data-track/handle';
|
|
59
59
|
import { type DataTrackSid } from '../room/data-track/types';
|
|
60
|
-
import { ConnectionError } from '../room/errors';
|
|
60
|
+
import { ConnectionError, ConnectionErrorReason } from '../room/errors';
|
|
61
61
|
import CriticalTimers from '../room/timers';
|
|
62
62
|
import type { LoggerOptions } from '../room/types';
|
|
63
63
|
import { getClientInfo, isCompressionStreamSupported, isReactNative, sleep } from '../room/utils';
|
|
64
64
|
import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
|
|
65
65
|
import { AsyncQueue } from '../utils/AsyncQueue';
|
|
66
|
+
import { announceMachine } from '../utils/machineInspector';
|
|
67
|
+
import {
|
|
68
|
+
type SignalLifecycleState,
|
|
69
|
+
type SignalMachine,
|
|
70
|
+
type SignalMachineInput,
|
|
71
|
+
createSignalMachine,
|
|
72
|
+
} from './SignalClientStateMachine';
|
|
66
73
|
import { type WebSocketConnection, WebSocketStream } from './WebSocketStream';
|
|
67
74
|
import {
|
|
68
75
|
createRtcUrl,
|
|
@@ -104,6 +111,20 @@ const passThroughQueueSignals: Array<SignalKind> = [
|
|
|
104
111
|
'leave',
|
|
105
112
|
];
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Whether a failed resume may be followed by another one. Mirrors how `RTCEngine` classifies these
|
|
116
|
+
* errors: a server leave ends the session, and an expired token cannot be recovered by retrying.
|
|
117
|
+
*/
|
|
118
|
+
function isRecoverableReconnectError(error: unknown): boolean {
|
|
119
|
+
if (error instanceof ConnectionError) {
|
|
120
|
+
return (
|
|
121
|
+
error.reason !== ConnectionErrorReason.LeaveRequest &&
|
|
122
|
+
error.reason !== ConnectionErrorReason.NotAllowed
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
function canPassThroughQueue(req: SignalMessage): boolean {
|
|
108
129
|
const canPass = passThroughQueueSignals.indexOf(req!.case) >= 0;
|
|
109
130
|
log.trace('request allowed to bypass queue:', { canPass, req });
|
|
@@ -118,6 +139,25 @@ export enum SignalConnectionState {
|
|
|
118
139
|
DISCONNECTED,
|
|
119
140
|
}
|
|
120
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Public projection of the lifecycle machine's states. `new`, `offline` and `closed` are all
|
|
144
|
+
* reported as `DISCONNECTED`: they differ in what may happen next
|
|
145
|
+
*/
|
|
146
|
+
function lifecycleToConnectionState(lifecycle: SignalLifecycleState): SignalConnectionState {
|
|
147
|
+
switch (lifecycle) {
|
|
148
|
+
case 'connected':
|
|
149
|
+
return SignalConnectionState.CONNECTED;
|
|
150
|
+
case 'connecting':
|
|
151
|
+
return SignalConnectionState.CONNECTING;
|
|
152
|
+
case 'reconnecting':
|
|
153
|
+
return SignalConnectionState.RECONNECTING;
|
|
154
|
+
case 'disconnecting':
|
|
155
|
+
return SignalConnectionState.DISCONNECTING;
|
|
156
|
+
default:
|
|
157
|
+
return SignalConnectionState.DISCONNECTED;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
/** specifies how much time (in ms) we allow for the ws to close its connection gracefully before continuing */
|
|
122
162
|
const MAX_WS_CLOSE_TIME = 250;
|
|
123
163
|
|
|
@@ -206,21 +246,47 @@ export class SignalClient {
|
|
|
206
246
|
ws?: WebSocketStream;
|
|
207
247
|
|
|
208
248
|
get currentState() {
|
|
209
|
-
return this.
|
|
249
|
+
return lifecycleToConnectionState(this.lifecycleState);
|
|
210
250
|
}
|
|
211
251
|
|
|
212
252
|
get isDisconnected() {
|
|
253
|
+
const state = this.currentState;
|
|
213
254
|
return (
|
|
214
|
-
|
|
215
|
-
this.state === SignalConnectionState.DISCONNECTED
|
|
255
|
+
state === SignalConnectionState.DISCONNECTING || state === SignalConnectionState.DISCONNECTED
|
|
216
256
|
);
|
|
217
257
|
}
|
|
218
258
|
|
|
259
|
+
/** Runtime lifecycle state, finer grained than the public {@link currentState} projection. */
|
|
260
|
+
private get lifecycleState(): SignalLifecycleState {
|
|
261
|
+
return this.machine.currentState();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Id of the current connection attempt and of the transport it owns. */
|
|
265
|
+
private get attemptId() {
|
|
266
|
+
return this.machine.context.attemptId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Applies a lifecycle input and reports whether it moved the machine
|
|
271
|
+
*/
|
|
272
|
+
private sendLifecycleInput(input: SignalMachineInput): boolean {
|
|
273
|
+
const before = this.lifecycleState;
|
|
274
|
+
this.machine.handle(input.type, input);
|
|
275
|
+
return this.lifecycleState !== before;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Waits out a close that is already in flight. Establishing a session while one is tearing down
|
|
280
|
+
* would race it for the transport — the teardown can close the socket the new attempt just
|
|
281
|
+
* opened.
|
|
282
|
+
*/
|
|
283
|
+
private async settleInFlightClose() {
|
|
284
|
+
this.log.debug('waiting for an in-flight close to settle before establishing a session');
|
|
285
|
+
(await this.closingLock.lock())();
|
|
286
|
+
}
|
|
287
|
+
|
|
219
288
|
private get isEstablishingConnection() {
|
|
220
|
-
return
|
|
221
|
-
this.state === SignalConnectionState.CONNECTING ||
|
|
222
|
-
this.state === SignalConnectionState.RECONNECTING
|
|
223
|
-
);
|
|
289
|
+
return this.lifecycleState === 'connecting' || this.lifecycleState === 'reconnecting';
|
|
224
290
|
}
|
|
225
291
|
|
|
226
292
|
private getNextRequestId() {
|
|
@@ -240,7 +306,7 @@ export class SignalClient {
|
|
|
240
306
|
|
|
241
307
|
private closingLock: Mutex;
|
|
242
308
|
|
|
243
|
-
private
|
|
309
|
+
private machine: SignalMachine;
|
|
244
310
|
|
|
245
311
|
private connectionLock: Mutex;
|
|
246
312
|
|
|
@@ -262,7 +328,17 @@ export class SignalClient {
|
|
|
262
328
|
this.queuedRequests = [];
|
|
263
329
|
this.closingLock = new Mutex();
|
|
264
330
|
this.connectionLock = new Mutex();
|
|
265
|
-
this.
|
|
331
|
+
this.machine = createSignalMachine();
|
|
332
|
+
this.machine.on('transitioned', ({ fromState, toState }) => {
|
|
333
|
+
this.log.debug(`signal lifecycle: ${fromState} -> ${toState}`);
|
|
334
|
+
});
|
|
335
|
+
this.machine.on('nohandler', ({ inputName }) => {
|
|
336
|
+
this.log.debug(
|
|
337
|
+
`ignoring signal lifecycle input ${inputName} in state ${this.lifecycleState}`,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
// no-op unless a development tool asked for it; see utils/machineInspector
|
|
341
|
+
announceMachine('signal', this.machine);
|
|
266
342
|
}
|
|
267
343
|
|
|
268
344
|
private get logContext() {
|
|
@@ -277,12 +353,27 @@ export class SignalClient {
|
|
|
277
353
|
useV0Path: boolean = false,
|
|
278
354
|
publisherOffer?: SessionDescription,
|
|
279
355
|
): Promise<JoinResponse> {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
356
|
+
if (this.lifecycleState === 'disconnecting') {
|
|
357
|
+
await this.settleInFlightClose();
|
|
358
|
+
}
|
|
359
|
+
if (!this.sendLifecycleInput({ type: 'connect' })) {
|
|
360
|
+
// Proceeding would open a transport the lifecycle does not own: its completion would be
|
|
361
|
+
// discarded and the session left claiming a transport that had been torn down. Every caller
|
|
362
|
+
// that restarts a session closes the previous one first (see RTCEngine.restartConnection).
|
|
363
|
+
throw ConnectionError.internal(
|
|
364
|
+
`cannot establish a signal session from '${this.lifecycleState}', close the current one first`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
283
367
|
this.options = opts;
|
|
284
|
-
|
|
285
|
-
|
|
368
|
+
try {
|
|
369
|
+
const res = await this.connect(url, token, opts, abortSignal, useV0Path, publisherOffer);
|
|
370
|
+
return res as JoinResponse;
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// reported here rather than at each rejection site inside connect(), so that no failure
|
|
373
|
+
// path can leave the machine stuck in `connecting`
|
|
374
|
+
this.sendLifecycleInput({ type: 'connectFailed', error: e });
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
286
377
|
}
|
|
287
378
|
|
|
288
379
|
async reconnect(
|
|
@@ -295,23 +386,39 @@ export class SignalClient {
|
|
|
295
386
|
this.log.warn('attempted to reconnect without signal options being set, ignoring');
|
|
296
387
|
return;
|
|
297
388
|
}
|
|
298
|
-
this.
|
|
389
|
+
if (this.lifecycleState === 'disconnecting') {
|
|
390
|
+
await this.settleInFlightClose();
|
|
391
|
+
}
|
|
392
|
+
if (!this.sendLifecycleInput({ type: 'reconnect' })) {
|
|
393
|
+
throw ConnectionError.internal(
|
|
394
|
+
`cannot resume the signal session from '${this.lifecycleState}'`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
299
397
|
// clear ping interval and restart it once reconnected
|
|
300
398
|
this.clearPingInterval();
|
|
301
399
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
400
|
+
try {
|
|
401
|
+
const res = (await this.connect(
|
|
402
|
+
url,
|
|
403
|
+
token,
|
|
404
|
+
{
|
|
405
|
+
...this.options,
|
|
406
|
+
reconnect: true,
|
|
407
|
+
sid,
|
|
408
|
+
reconnectReason: reason,
|
|
409
|
+
},
|
|
410
|
+
undefined,
|
|
411
|
+
this.useV0SignalPath,
|
|
412
|
+
)) as ReconnectResponse | undefined;
|
|
413
|
+
return res;
|
|
414
|
+
} catch (e) {
|
|
415
|
+
this.sendLifecycleInput({
|
|
416
|
+
type: 'reconnectFailed',
|
|
417
|
+
error: e,
|
|
418
|
+
recoverable: isRecoverableReconnectError(e),
|
|
419
|
+
});
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
315
422
|
}
|
|
316
423
|
|
|
317
424
|
private async connect(
|
|
@@ -357,7 +464,15 @@ export class SignalClient {
|
|
|
357
464
|
this.close();
|
|
358
465
|
}
|
|
359
466
|
cleanupAbortHandlers();
|
|
360
|
-
|
|
467
|
+
// The caller may already have classified this failure: the connect timeout hands us a
|
|
468
|
+
// Timeout error. Only a genuine abort is a cancellation, and reporting a stalled connect as
|
|
469
|
+
// one makes the engine read it as user intent — skipping region failover and never
|
|
470
|
+
// recording the attempt against the backoff strategy (see Room.connect).
|
|
471
|
+
reject(
|
|
472
|
+
eventOrError instanceof ConnectionError
|
|
473
|
+
? eventOrError
|
|
474
|
+
: ConnectionError.cancelled(reason),
|
|
475
|
+
);
|
|
361
476
|
};
|
|
362
477
|
|
|
363
478
|
abortSignal?.addEventListener('abort', abortHandler);
|
|
@@ -371,13 +486,6 @@ export class SignalClient {
|
|
|
371
486
|
abortHandler(ConnectionError.timeout('room connection has timed out (signal)'));
|
|
372
487
|
}, opts.websocketTimeout);
|
|
373
488
|
|
|
374
|
-
const handleSignalConnected = (
|
|
375
|
-
connection: WebSocketConnection,
|
|
376
|
-
firstMessage?: SignalResponse,
|
|
377
|
-
) => {
|
|
378
|
-
this.handleSignalConnected(connection, wsTimeout, firstMessage);
|
|
379
|
-
};
|
|
380
|
-
|
|
381
489
|
const redactedUrl = new URL(rtcUrl);
|
|
382
490
|
if (redactedUrl.searchParams.has('access_token')) {
|
|
383
491
|
redactedUrl.searchParams.set('access_token', '<redacted>');
|
|
@@ -385,41 +493,65 @@ export class SignalClient {
|
|
|
385
493
|
|
|
386
494
|
if (this.ws) {
|
|
387
495
|
const startClose = performance.now();
|
|
388
|
-
await this.
|
|
496
|
+
await this.teardownTransport('replaced by a new connection attempt');
|
|
389
497
|
this.log.debug(`closed previous ws connection in ${performance.now() - startClose}ms`);
|
|
390
498
|
}
|
|
391
499
|
|
|
500
|
+
// the transport created below belongs to this attempt; events arriving from it after a
|
|
501
|
+
// newer attempt has started are dropped by the machine
|
|
502
|
+
const attemptId = this.attemptId;
|
|
503
|
+
|
|
392
504
|
this.log.info(`signal connecting to ${redactedUrl}`, {
|
|
393
505
|
reconnect: opts.reconnect,
|
|
394
506
|
reconnectReason: opts.reconnectReason,
|
|
395
507
|
});
|
|
396
508
|
this.ws = new WebSocketStream<ArrayBuffer>(rtcUrl);
|
|
397
509
|
|
|
510
|
+
// A failed upgrade closes the socket, so `opened` and `closed` settle for the same cause — but
|
|
511
|
+
// only the `opened` path can classify it (a 401 is known after asking the validate endpoint).
|
|
512
|
+
// Noting the failure *before* the close handler is registered means it runs first, so a close
|
|
513
|
+
// that merely reflects a failed upgrade stands down and lets the classified error reach the
|
|
514
|
+
// caller. Any other close still fails the attempt straight away: after a successful upgrade
|
|
515
|
+
// nothing else would, short of the first-message timeout.
|
|
516
|
+
let upgradeFailed = false;
|
|
517
|
+
this.ws.opened.catch(() => {
|
|
518
|
+
upgradeFailed = true;
|
|
519
|
+
});
|
|
520
|
+
|
|
398
521
|
try {
|
|
399
522
|
this.ws.closed
|
|
400
523
|
.then((closeInfo) => {
|
|
401
|
-
if (this.isEstablishingConnection) {
|
|
524
|
+
if (this.isEstablishingConnection && !upgradeFailed) {
|
|
402
525
|
reject(
|
|
403
526
|
ConnectionError.internal(
|
|
404
527
|
`Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`,
|
|
405
528
|
),
|
|
406
529
|
);
|
|
407
530
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
531
|
+
this.log.debug('websocket closed', {
|
|
532
|
+
reason: closeInfo.reason,
|
|
533
|
+
code: closeInfo.closeCode,
|
|
534
|
+
attemptId,
|
|
535
|
+
state: this.lifecycleState,
|
|
536
|
+
});
|
|
537
|
+
// Every close of the live transport is reported, including a clean 1000 one: a server
|
|
538
|
+
// that drops signalling closes cleanly — a migration that never sends its
|
|
539
|
+
// `Leave{action=RESUME}` does exactly that — and treating that as "nothing happened"
|
|
540
|
+
// leaves the client believing it is still connected until something else notices.
|
|
541
|
+
// Closes we caused ourselves, and closes from a transport that has since been
|
|
542
|
+
// replaced, are dropped by the machine's state and attempt guards below rather than by
|
|
543
|
+
// a close-code test.
|
|
544
|
+
this.handleOnClose(
|
|
545
|
+
closeInfo.reason ||
|
|
546
|
+
(closeInfo.closeCode === 1000
|
|
547
|
+
? 'server closed the signal connection'
|
|
548
|
+
: 'Unexpected WS error'),
|
|
549
|
+
attemptId,
|
|
550
|
+
);
|
|
419
551
|
return;
|
|
420
552
|
})
|
|
421
553
|
.catch((reason) => {
|
|
422
|
-
if (this.isEstablishingConnection) {
|
|
554
|
+
if (this.isEstablishingConnection && !upgradeFailed) {
|
|
423
555
|
reject(
|
|
424
556
|
ConnectionError.internal(
|
|
425
557
|
`Websocket error during a (re)connection attempt: ${reason}`,
|
|
@@ -428,8 +560,9 @@ export class SignalClient {
|
|
|
428
560
|
}
|
|
429
561
|
});
|
|
430
562
|
const connection = await this.ws.opened.catch(async (reason: unknown) => {
|
|
431
|
-
if (this.
|
|
432
|
-
|
|
563
|
+
if (this.lifecycleState !== 'connected') {
|
|
564
|
+
// claimed synchronously, before the await below: the socket's close event is already
|
|
565
|
+
// on its way and would otherwise reject first with a less useful error
|
|
433
566
|
clearTimeout(wsTimeout);
|
|
434
567
|
const error = await this.handleConnectionError(reason, validateUrl);
|
|
435
568
|
reject(error);
|
|
@@ -514,7 +647,7 @@ export class SignalClient {
|
|
|
514
647
|
const firstMessageToProcess = validation.shouldProcessFirstMessage
|
|
515
648
|
? firstSignalResponse
|
|
516
649
|
: undefined;
|
|
517
|
-
handleSignalConnected(connection, firstMessageToProcess);
|
|
650
|
+
this.handleSignalConnected(connection, wsTimeout, attemptId, firstMessageToProcess);
|
|
518
651
|
resolve(validation.response);
|
|
519
652
|
} catch (e) {
|
|
520
653
|
reject(e);
|
|
@@ -538,12 +671,18 @@ export class SignalClient {
|
|
|
538
671
|
if (this.signalLatency) {
|
|
539
672
|
await sleep(this.signalLatency);
|
|
540
673
|
}
|
|
541
|
-
|
|
542
|
-
|
|
674
|
+
try {
|
|
675
|
+
const { done, value } = await signalReader.read();
|
|
676
|
+
if (done) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
const resp = parseSignalResponse(value);
|
|
680
|
+
this.handleSignalResponse(resp);
|
|
681
|
+
} catch (e) {
|
|
682
|
+
this.log.error(`error reading from signal stream`, { error: e });
|
|
683
|
+
await this.close(false, 'error in reading loop');
|
|
543
684
|
break;
|
|
544
685
|
}
|
|
545
|
-
const resp = parseSignalResponse(value);
|
|
546
|
-
this.handleSignalResponse(resp);
|
|
547
686
|
}
|
|
548
687
|
}
|
|
549
688
|
|
|
@@ -564,20 +703,28 @@ export class SignalClient {
|
|
|
564
703
|
};
|
|
565
704
|
|
|
566
705
|
async close(updateState: boolean = true, reason = 'Close method called on signal client') {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
)
|
|
571
|
-
) {
|
|
572
|
-
this.log.debug(`ignoring signal close as it's already in disconnecting state`);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
706
|
+
// when the lifecycle is already shutting down (or another close owns it), only the transport
|
|
707
|
+
// teardown below still applies — it is idempotent, so callers can always await a close
|
|
708
|
+
const drivesLifecycle = updateState && this.sendLifecycleInput({ type: 'close', reason });
|
|
575
709
|
const unlock = await this.closingLock.lock();
|
|
576
710
|
try {
|
|
577
|
-
this.
|
|
578
|
-
|
|
579
|
-
|
|
711
|
+
await this.teardownTransport(reason);
|
|
712
|
+
} finally {
|
|
713
|
+
if (drivesLifecycle) {
|
|
714
|
+
this.sendLifecycleInput({ type: 'closeComplete' });
|
|
580
715
|
}
|
|
716
|
+
unlock();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Releases the transport and everything tied to it, without touching the lifecycle state. Used
|
|
722
|
+
* both by {@link close} and by paths that replace the transport under a live lifecycle (a new
|
|
723
|
+
* attempt, or an unexpected close that leaves the client in `offline`).
|
|
724
|
+
*/
|
|
725
|
+
private async teardownTransport(reason: string) {
|
|
726
|
+
try {
|
|
727
|
+
this.clearPingInterval();
|
|
581
728
|
if (this.ws) {
|
|
582
729
|
this.ws.close({ closeCode: 1000, reason });
|
|
583
730
|
|
|
@@ -589,11 +736,6 @@ export class SignalClient {
|
|
|
589
736
|
}
|
|
590
737
|
} catch (e) {
|
|
591
738
|
this.log.debug('websocket error while closing', { error: e });
|
|
592
|
-
} finally {
|
|
593
|
-
if (updateState) {
|
|
594
|
-
this.state = SignalConnectionState.DISCONNECTED;
|
|
595
|
-
}
|
|
596
|
-
unlock();
|
|
597
739
|
}
|
|
598
740
|
}
|
|
599
741
|
|
|
@@ -781,7 +923,9 @@ export class SignalClient {
|
|
|
781
923
|
// capture all requests while reconnecting and put them in a queue
|
|
782
924
|
// unless the request originates from the queue, then don't enqueue again
|
|
783
925
|
const canQueue = !fromQueue && !canPassThroughQueue(message);
|
|
784
|
-
|
|
926
|
+
const isHoldingRequests =
|
|
927
|
+
this.lifecycleState === 'reconnecting' || this.queuedRequests.length > 0;
|
|
928
|
+
if (canQueue && isHoldingRequests) {
|
|
785
929
|
this.queuedRequests.push(async () => {
|
|
786
930
|
await this.sendRequest(message, true);
|
|
787
931
|
});
|
|
@@ -794,7 +938,11 @@ export class SignalClient {
|
|
|
794
938
|
if (this.signalLatency) {
|
|
795
939
|
await sleep(this.signalLatency);
|
|
796
940
|
}
|
|
797
|
-
|
|
941
|
+
// `leave` is the one request whose purpose is to be sent on the way out (an aborted connect
|
|
942
|
+
// attempt tells the server before tearing down), so it is allowed through for as long as the
|
|
943
|
+
// transport is still there
|
|
944
|
+
const isLeaveOnShutdown = message.case === 'leave' && !!this.streamWriter;
|
|
945
|
+
if (this.isDisconnected && !isLeaveOnShutdown) {
|
|
798
946
|
// Skip requests if the signal layer is disconnected
|
|
799
947
|
// This can happen if an event is sent in the mist of room.connect() initializing
|
|
800
948
|
this.log.debug(`skipping signal request (type: ${message.case}) - SignalClient disconnected`);
|
|
@@ -946,10 +1094,24 @@ export class SignalClient {
|
|
|
946
1094
|
}
|
|
947
1095
|
}
|
|
948
1096
|
|
|
949
|
-
|
|
950
|
-
|
|
1097
|
+
/**
|
|
1098
|
+
* Handles a transport we lost without asking to. The client goes to `offline` rather than
|
|
1099
|
+
* `closed`: whether this session gets resumed, restarted or given up on is the engine's call.
|
|
1100
|
+
*/
|
|
1101
|
+
private async handleOnClose(reason: string, attemptId: number = this.attemptId) {
|
|
951
1102
|
const onCloseCallback = this.onClose;
|
|
952
|
-
|
|
1103
|
+
if (!this.sendLifecycleInput({ type: 'transportFailed', attemptId, reason })) {
|
|
1104
|
+
// a close we caused ourselves, or one from a transport that has since been replaced: logged
|
|
1105
|
+
// rather than dropped silently, because a close that goes unnoticed leaves the client writing
|
|
1106
|
+
// to a dead socket
|
|
1107
|
+
this.log.debug(`ignoring transport close in state ${this.lifecycleState}`, {
|
|
1108
|
+
reason,
|
|
1109
|
+
attemptId,
|
|
1110
|
+
currentAttemptId: this.attemptId,
|
|
1111
|
+
});
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
await this.teardownTransport(reason);
|
|
953
1115
|
this.log.info(`websocket connection closed: ${reason}`, { reason });
|
|
954
1116
|
if (onCloseCallback) {
|
|
955
1117
|
onCloseCallback(reason);
|
|
@@ -1020,11 +1182,27 @@ export class SignalClient {
|
|
|
1020
1182
|
private handleSignalConnected(
|
|
1021
1183
|
connection: WebSocketConnection,
|
|
1022
1184
|
timeoutHandle: ReturnType<typeof setTimeout>,
|
|
1185
|
+
attemptId: number,
|
|
1023
1186
|
firstMessage?: SignalResponse,
|
|
1024
1187
|
) {
|
|
1025
|
-
this.state = SignalConnectionState.CONNECTED;
|
|
1026
|
-
this.log.info('signal connected');
|
|
1027
1188
|
clearTimeout(timeoutHandle);
|
|
1189
|
+
const established = this.sendLifecycleInput(
|
|
1190
|
+
this.lifecycleState === 'reconnecting'
|
|
1191
|
+
? { type: 'reconnectComplete', attemptId }
|
|
1192
|
+
: { type: 'connectComplete', attemptId },
|
|
1193
|
+
);
|
|
1194
|
+
if (!established) {
|
|
1195
|
+
// The attempt was abandoned while we waited for the server's first message: it was closed, or
|
|
1196
|
+
// a newer attempt superseded it. Arming the ping interval and the read loop here would outlive
|
|
1197
|
+
// the session that owns them, so leave the transport to whoever holds the lifecycle now.
|
|
1198
|
+
this.log.debug('discarding a connection whose attempt no longer owns the session', {
|
|
1199
|
+
attemptId,
|
|
1200
|
+
currentAttemptId: this.attemptId,
|
|
1201
|
+
state: this.lifecycleState,
|
|
1202
|
+
});
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
this.log.info('signal connected');
|
|
1028
1206
|
this.startPingInterval();
|
|
1029
1207
|
this.startReadingLoop(connection.readable.getReader(), firstMessage);
|
|
1030
1208
|
}
|
|
@@ -1051,7 +1229,7 @@ export class SignalClient {
|
|
|
1051
1229
|
response: firstSignalResponse.message.value,
|
|
1052
1230
|
};
|
|
1053
1231
|
} else if (
|
|
1054
|
-
this.
|
|
1232
|
+
this.lifecycleState === 'reconnecting' &&
|
|
1055
1233
|
firstSignalResponse.message?.case !== 'leave'
|
|
1056
1234
|
) {
|
|
1057
1235
|
if (firstSignalResponse.message?.case === 'reconnect') {
|