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.
Files changed (75) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +187 -1
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1764 -121
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +187 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +25 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  16. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/logger.d.ts +2 -1
  22. package/dist/src/logger.d.ts.map +1 -1
  23. package/dist/src/options.d.ts +22 -0
  24. package/dist/src/options.d.ts.map +1 -1
  25. package/dist/src/room/PCTransport.d.ts +2 -1
  26. package/dist/src/room/PCTransport.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +7 -2
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/Room.d.ts +8 -1
  30. package/dist/src/room/Room.d.ts.map +1 -1
  31. package/dist/src/room/events.d.ts +1 -1
  32. package/dist/src/room/statsSummary.d.ts +13 -0
  33. package/dist/src/room/statsSummary.d.ts.map +1 -0
  34. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  35. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  36. package/dist/src/room/track/Track.d.ts +10 -0
  37. package/dist/src/room/track/Track.d.ts.map +1 -1
  38. package/dist/src/utils/machineInspector.d.ts +54 -0
  39. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  40. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  41. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  42. package/dist/ts4.2/index.d.ts +3 -2
  43. package/dist/ts4.2/logger.d.ts +2 -1
  44. package/dist/ts4.2/options.d.ts +22 -0
  45. package/dist/ts4.2/room/PCTransport.d.ts +2 -1
  46. package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
  47. package/dist/ts4.2/room/Room.d.ts +8 -1
  48. package/dist/ts4.2/room/events.d.ts +1 -1
  49. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  50. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  51. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  52. package/package.json +7 -1
  53. package/src/api/SignalClient.test.ts +320 -8
  54. package/src/api/SignalClient.ts +260 -82
  55. package/src/api/SignalClientStateMachine.test.ts +472 -0
  56. package/src/api/SignalClientStateMachine.ts +180 -0
  57. package/src/api/WebSocketStream.ts +19 -3
  58. package/src/api/utils.test.ts +20 -1
  59. package/src/api/utils.ts +5 -0
  60. package/src/index.ts +5 -0
  61. package/src/logger.ts +1 -0
  62. package/src/options.ts +24 -0
  63. package/src/room/PCTransport.ts +2 -1
  64. package/src/room/RTCEngine.ts +16 -7
  65. package/src/room/Room.ts +70 -5
  66. package/src/room/events.ts +1 -1
  67. package/src/room/statsSummary.ts +187 -0
  68. package/src/room/token-source/test-tokens.ts +20 -0
  69. package/src/room/token-source/utils.test.ts +27 -0
  70. package/src/room/token-source/utils.ts +12 -5
  71. package/src/room/track/LocalAudioTrack.ts +9 -3
  72. package/src/room/track/Track.ts +27 -0
  73. package/src/room/utils.test.ts +24 -1
  74. package/src/room/utils.ts +1 -1
  75. package/src/utils/machineInspector.ts +90 -0
@@ -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.state;
249
+ return lifecycleToConnectionState(this.lifecycleState);
210
250
  }
211
251
 
212
252
  get isDisconnected() {
253
+ const state = this.currentState;
213
254
  return (
214
- this.state === SignalConnectionState.DISCONNECTING ||
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 state: SignalConnectionState = SignalConnectionState.DISCONNECTED;
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.state = SignalConnectionState.DISCONNECTED;
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
- // during a full reconnect, we'd want to start the sequence even if currently
281
- // connected
282
- this.state = SignalConnectionState.CONNECTING;
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
- const res = await this.connect(url, token, opts, abortSignal, useV0Path, publisherOffer);
285
- return res as JoinResponse;
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.state = SignalConnectionState.RECONNECTING;
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
- const res = (await this.connect(
303
- url,
304
- token,
305
- {
306
- ...this.options,
307
- reconnect: true,
308
- sid,
309
- reconnectReason: reason,
310
- },
311
- undefined,
312
- this.useV0SignalPath,
313
- )) as ReconnectResponse | undefined;
314
- return res;
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
- reject(ConnectionError.cancelled(reason));
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.close(false);
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
- if (closeInfo.closeCode !== 1000) {
409
- this.log.warn(`websocket closed`, {
410
- reason: closeInfo.reason,
411
- code: closeInfo.closeCode,
412
- wasClean: closeInfo.closeCode === 1000,
413
- state: this.state,
414
- });
415
- if (this.state === SignalConnectionState.CONNECTED) {
416
- this.handleOnClose(closeInfo.reason || 'Unexpected WS error');
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.state !== SignalConnectionState.CONNECTED) {
432
- this.state = SignalConnectionState.DISCONNECTED;
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
- const { done, value } = await signalReader.read();
542
- if (done) {
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
- if (
568
- [SignalConnectionState.DISCONNECTING || SignalConnectionState.DISCONNECTED].includes(
569
- this.state,
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.clearPingInterval();
578
- if (updateState) {
579
- this.state = SignalConnectionState.DISCONNECTING;
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
- if (canQueue && this.state === SignalConnectionState.RECONNECTING) {
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
- if (this.isDisconnected) {
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
- private async handleOnClose(reason: string) {
950
- if (this.state === SignalConnectionState.DISCONNECTED) return;
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
- await this.close(undefined, reason);
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.state === SignalConnectionState.RECONNECTING &&
1232
+ this.lifecycleState === 'reconnecting' &&
1055
1233
  firstSignalResponse.message?.case !== 'leave'
1056
1234
  ) {
1057
1235
  if (firstSignalResponse.message?.case === 'reconnect') {