@voltkit-rtc/client 0.7.0 → 0.7.2

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/src/index.ts CHANGED
@@ -349,7 +349,11 @@ export interface ClientEvents extends ChatEvents {
349
349
  localStreamChanged: (stream: MediaStream | null) => void;
350
350
  /** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
351
351
  streamInterrupted: (e: { reason: string }) => void;
352
- /** The session ended and will not recover. Nothing further will be emitted. */
352
+ /**
353
+ * The session ended and will not recover. Nothing further will be emitted.
354
+ * Whatever the reason, the camera, microphone, screen, music and recording
355
+ * are released with it, as close() does.
356
+ */
353
357
  closed: (reason: ClosedReason) => void;
354
358
  /**
355
359
  * The account behind this session ran out of minutes or reached a usage
@@ -411,6 +415,8 @@ interface Pending {
411
415
  resolve: (v: unknown) => void;
412
416
  reject: (e: Error) => void;
413
417
  timer: ReturnType<typeof setTimeout>;
418
+ /** The socket it was sent on: only that socket can answer it. */
419
+ ws: WebSocket;
414
420
  }
415
421
 
416
422
  const RPC_TIMEOUT_MS = 15_000;
@@ -436,6 +442,9 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
436
442
  */
437
443
  const VIDEO_START_BITRATE_KBPS = 2000;
438
444
 
445
+ /** Why a connect() gave up: close() ran while it was under way, and close wins. */
446
+ const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
447
+
439
448
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
440
449
  const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
441
450
 
@@ -488,6 +497,16 @@ export class StreamClient {
488
497
  /** In-app music being published, kept across reconnects so it can be published again. */
489
498
  private music: MusicSource | null = null;
490
499
  private musicProducerId: string | null = null;
500
+ /**
501
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
502
+ * being made when it changes belongs to a publish that is over: it is
503
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
504
+ * music went out after stopMusic() had returned.
505
+ */
506
+ private musicOperation = 0;
507
+ // Music publishes run one at a time: the server takes one producer per
508
+ // source, so the next is produced only once the last one is settled.
509
+ private musicQueue: Promise<unknown> = Promise.resolve();
491
510
  private screenStream: MediaStream | null = null;
492
511
  private screenMode: ScreenMode = 'detail';
493
512
  private screenAudioEnabled = true;
@@ -504,13 +523,23 @@ export class StreamClient {
504
523
  private frameRateWarmupSamples = 2;
505
524
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
506
525
  private readonly remoteSources = new Map<string, TrackSource>();
526
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
527
+ private readonly subscriptions = new Map<string, object>();
507
528
  /**
508
529
  * The layers asked for each remote video, by producerId. A reconnect
509
530
  * consumes everything again, and the new consumer would otherwise start at
510
531
  * the full picture.
511
532
  */
512
533
  private readonly preferredLayers = new Map<string, PreferredLayers>();
534
+ /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
535
+ private readonly endRoomWaiters = new Set<() => void>();
513
536
  private closedByUs = false;
537
+ /**
538
+ * Bumped when the session ends for good: close(), or finish(). A connect()
539
+ * under way when it did -- the app's, or a reconnect's -- sees the change
540
+ * after its next await and stops, so a closed session never comes back.
541
+ */
542
+ private lifecycle = 0;
514
543
  private reconnecting = false;
515
544
  private reconnectAttempt = 0;
516
545
  /** Set once a connect() has joined and set up. Only such a session is reconnected. */
@@ -539,6 +568,17 @@ export class StreamClient {
539
568
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
540
569
  private tokenExpiry: number | null;
541
570
  private tokenTimer: ReturnType<typeof setTimeout> | null = null;
571
+ /**
572
+ * Bumped whenever the server hands this client a token (its own refresh, a
573
+ * promotion's). An updateToken() that started before then keeps the token
574
+ * it was given only if nothing newer arrived while it waited: its reply,
575
+ * or its fallback once the socket closed, used to replace a promotion's
576
+ * token, and the reconnect joined with the old one.
577
+ */
578
+ private serverTokenRevision = 0;
579
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
580
+ private tokenRequests = 0;
581
+ private tokenRequestHeld = 0;
542
582
  /** Whose each received track is and whether it is switched off, by producerId. */
543
583
  private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
544
584
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -597,10 +637,23 @@ export class StreamClient {
597
637
  /** Over for good: the state, then the event that says why. */
598
638
  private finish(reason: ClosedReason): void {
599
639
  this.closedByUs = true;
640
+ this.lifecycle += 1;
600
641
  this.setState('closed');
601
642
  this.emit('closed', reason);
602
643
  }
603
644
 
645
+ /**
646
+ * Over for good without the app asking: finish, then release everything
647
+ * local as close() does -- camera, microphone, screen, music, recording.
648
+ * kicked and roomClosed always did; a replaced socket, a close without
649
+ * roomClosed and a reconnect that gave up left them capturing (and the
650
+ * recording uploading) after `closed`, until the app called close().
651
+ */
652
+ private end(reason: ClosedReason): void {
653
+ this.finish(reason);
654
+ this.close();
655
+ }
656
+
604
657
  private stopTimers(): void {
605
658
  if (this.tokenTimer) clearTimeout(this.tokenTimer);
606
659
  this.tokenTimer = null;
@@ -624,20 +677,31 @@ export class StreamClient {
624
677
  // ------------------------------------------------------------- connection
625
678
 
626
679
  async connect(): Promise<JoinResult> {
680
+ const lifecycle = this.lifecycle;
681
+ const closed = () => lifecycle !== this.lifecycle;
627
682
  let result: JoinResult;
628
683
  if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
684
+ // The socket this call opens. A later connect() replaces it, and this
685
+ // call's failure must then leave the newer socket, and the state, alone.
686
+ let socket: WebSocket | null = null;
629
687
  try {
630
- await this.openSocket();
688
+ const opening = this.openSocket();
689
+ socket = this.ws;
690
+ await opening;
631
691
  result = (await this.rpc('join', { token: this.token })) as JoinResult;
632
692
  this.joinResult = result;
633
693
 
634
694
  this.device = this.createDevice();
635
695
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
696
+ // close() ran while the Device loaded: going on would set the state
697
+ // back to connected, and emit joined, after closed.
698
+ if (closed()) throw closedWhileConnecting();
636
699
  } catch (err) {
637
700
  // A session that never got going: the app has the error. Left open,
638
701
  // the socket would be closed by the server's join deadline instead, and
639
702
  // that close would start reconnecting a session that never began.
640
- if (!this.joinedOnce) {
703
+ const superseded = this.ws !== null && this.ws !== socket;
704
+ if (!this.joinedOnce && !superseded) {
641
705
  this.detachSocket();
642
706
  if (this.state === 'connecting') this.setState('idle');
643
707
  }
@@ -657,6 +721,7 @@ export class StreamClient {
657
721
  // decides what may return; reconnecting never turns a blocked device on.
658
722
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
659
723
  await this.unpublish();
724
+ if (closed()) throw closedWhileConnecting();
660
725
  }
661
726
  const own = result.participants.find((p) => p.identity === result.identity);
662
727
  if (own?.muted) this.applyLocalModeration('audio');
@@ -677,16 +742,19 @@ export class StreamClient {
677
742
  for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
678
743
 
679
744
  // Subscribe to everything already publishing before we announce readiness.
745
+ // A close() part-way stops it: what is left would only fail, after closed.
680
746
  for (const p of result.producers) {
681
- await this.consume(p).catch((err) => this.emit('error', err as Error));
747
+ if (closed()) throw closedWhileConnecting();
748
+ await this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); });
682
749
  }
683
750
 
684
751
  // Restore our own broadcast. This is what makes an origin failover
685
752
  // survivable for the host: it reconnects onto the new origin and the
686
753
  // stream comes back on its own.
687
754
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
688
- await this.republish().catch((err) => this.emit('error', err as Error));
755
+ await this.republish().catch((err) => { if (!closed()) this.emit('error', err as Error); });
689
756
  }
757
+ if (closed()) throw closedWhileConnecting();
690
758
  this.syncNetworkWatch();
691
759
  return result;
692
760
  }
@@ -735,17 +803,16 @@ export class StreamClient {
735
803
  // Another session for this identity is live. Reconnecting would
736
804
  // displace it, and it would displace us right back, forever.
737
805
  this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
738
- // The producers outlive this close until the app calls close(); the
739
- // videoLimited sampling must not, or it reports after `closed`.
806
+ // The videoLimited sampling stops first, or it reports after `closed`.
740
807
  this.limitWatch.reset();
741
- this.finish('replaced');
808
+ this.end('replaced');
742
809
  return;
743
810
  }
744
811
  if (ev.code === CLOSE_ENDED) {
745
812
  // Normally roomClosed has already ended the session; this covers a
746
813
  // close that arrived without it.
747
814
  this.limitWatch.reset();
748
- this.finish('room-closed');
815
+ this.end('room-closed');
749
816
  return;
750
817
  }
751
818
  // Before a first successful join the app is still waiting on
@@ -758,16 +825,32 @@ export class StreamClient {
758
825
  });
759
826
  }
760
827
 
761
- private detachSocket(): void {
828
+ private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
762
829
  const ws = this.ws;
763
830
  this.ws = null;
764
- if (ws && ws.readyState <= WebSocket.OPEN) {
831
+ if (!ws) return;
832
+ if (ws.readyState <= WebSocket.OPEN) {
765
833
  try {
766
- ws.close(1000, 'superseded');
834
+ ws.close(1000, reason);
767
835
  } catch {
768
836
  /* already closing */
769
837
  }
770
838
  }
839
+ // Its replies are no longer read, so what waits on it would only wait out
840
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
841
+ // server may already have acted on it.
842
+ this.failPending(ws, new RpcError('not-connected', why));
843
+ }
844
+
845
+ /**
846
+ * A reconnect that ends the session lets go of its socket. A refused join
847
+ * leaves that socket open (connect() keeps it once a session has joined),
848
+ * and the session would otherwise report closed with it still attached and
849
+ * still sending.
850
+ */
851
+ private releaseSocket(): void {
852
+ this.detachSocket('gave up', 'the session has ended');
853
+ this.failAllPending(new RpcError('not-connected', 'the session has ended'));
771
854
  }
772
855
 
773
856
  /**
@@ -786,7 +869,8 @@ export class StreamClient {
786
869
  this.reconnectAttempt += 1;
787
870
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
788
871
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
789
- this.finish('gave-up');
872
+ this.releaseSocket();
873
+ this.end('gave-up');
790
874
  return;
791
875
  }
792
876
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -801,12 +885,16 @@ export class StreamClient {
801
885
  // for good, with no `closed`.
802
886
  if (this.ws) return;
803
887
  } catch (err) {
888
+ // Ended while this attempt was under way -- by close(), or by the
889
+ // server -- and that has been reported: nothing follows closed.
890
+ if (this.closedByUs) return;
804
891
  this.emit('error', err as Error);
805
892
  // Stop on failures a retry cannot resolve (the registry's terminal
806
893
  // codes). Hammering a room that has ended produces one error per
807
894
  // attempt forever and drowns out whatever actually went wrong.
808
895
  if (err instanceof RpcError && isTerminalError(err.code)) {
809
- this.finish('gave-up');
896
+ this.releaseSocket();
897
+ this.end('gave-up');
810
898
  return;
811
899
  }
812
900
  }
@@ -822,6 +910,7 @@ export class StreamClient {
822
910
  // others have already reported themselves and set closedByUs.
823
911
  const wasOpen = !this.closedByUs;
824
912
  this.closedByUs = true;
913
+ this.lifecycle += 1;
825
914
  this.publishGeneration += 1;
826
915
  this.wasPublishing = false;
827
916
  this.teardownMedia();
@@ -841,11 +930,16 @@ export class StreamClient {
841
930
  const ws = this.ws;
842
931
  this.ws = null;
843
932
  ws?.close(1000, 'client closed');
933
+ // Detached first, so the socket's own close handler no longer fails what
934
+ // was waiting on it: done here, or a join or a request in flight would
935
+ // wait out its 15 s timeout.
936
+ this.failAllPending(new RpcError('not-connected', 'the session was closed'));
844
937
  this.setState('closed');
845
938
  if (wasOpen) this.emit('closed', 'left');
846
939
  }
847
940
 
848
941
  private teardownMedia(): void {
942
+ this.subscriptions.clear();
849
943
  for (const c of this.consumers.values()) c.close();
850
944
  this.consumers.clear();
851
945
  this.remoteInfo.clear();
@@ -920,17 +1014,27 @@ export class StreamClient {
920
1014
  * `tokenExpiring`. On a live connection the server checks it like a join
921
1015
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
922
1016
  * session then keeps its token); otherwise it is kept for the next join,
923
- * which checks it there. Resolves to when it expires (unix ms), when known.
1017
+ * which checks it there. Resolves to when the token now held expires (unix
1018
+ * ms), when known. A newer token that arrives while this waits is kept
1019
+ * instead of this one: the server's (its own refresh, or a promotion's,
1020
+ * which carries the new role) or a later updateToken()'s.
924
1021
  */
925
1022
  async updateToken(token: string): Promise<{ expiresAt: number | null }> {
926
1023
  if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
927
1024
  if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
1025
+ const request = ++this.tokenRequests;
1026
+ const revision = this.serverTokenRevision;
1027
+ const keep = (expiresAt: number | null) => {
1028
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
1029
+ this.tokenRequestHeld = request;
1030
+ this.setToken(token, expiresAt);
1031
+ }
1032
+ return { expiresAt: this.tokenExpiry };
1033
+ };
928
1034
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
929
1035
  try {
930
1036
  const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
931
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
932
- this.setToken(token, at);
933
- return { expiresAt: this.tokenExpiry };
1037
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
934
1038
  } catch (err) {
935
1039
  // An older server has no refreshToken, and a socket that dropped
936
1040
  // during the call is reconnecting: either way keep the token for the
@@ -938,8 +1042,7 @@ export class StreamClient {
938
1042
  if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
939
1043
  }
940
1044
  }
941
- this.setToken(token, null);
942
- return { expiresAt: this.tokenExpiry };
1045
+ return keep(null);
943
1046
  }
944
1047
 
945
1048
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
@@ -1003,11 +1106,21 @@ export class StreamClient {
1003
1106
  this.pending.delete(id);
1004
1107
  reject(new RpcError('timeout', `${method} timed out`));
1005
1108
  }, RPC_TIMEOUT_MS);
1006
- this.pending.set(id, { resolve, reject, timer });
1109
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
1007
1110
  this.ws.send(JSON.stringify({ id, method, params }));
1008
1111
  });
1009
1112
  }
1010
1113
 
1114
+ /** Fail what was sent on `ws` and is still waiting. */
1115
+ private failPending(ws: WebSocket, err: Error): void {
1116
+ for (const [id, p] of this.pending) {
1117
+ if (p.ws !== ws) continue;
1118
+ clearTimeout(p.timer);
1119
+ this.pending.delete(id);
1120
+ p.reject(err);
1121
+ }
1122
+ }
1123
+
1011
1124
  private failAllPending(err: Error): void {
1012
1125
  for (const p of this.pending.values()) {
1013
1126
  clearTimeout(p.timer);
@@ -1023,6 +1136,9 @@ export class StreamClient {
1023
1136
  } catch {
1024
1137
  return;
1025
1138
  }
1139
+ // Valid JSON that is not a message object ("null" would throw below) is
1140
+ // ignored like any other frame that is not a message.
1141
+ if (typeof msg !== 'object' || msg === null) return;
1026
1142
 
1027
1143
  if (typeof msg.id === 'number') {
1028
1144
  const p = this.pending.get(msg.id);
@@ -1048,21 +1164,20 @@ export class StreamClient {
1048
1164
  break;
1049
1165
  case 'producerClosed': {
1050
1166
  const { producerId, identity } = data as { producerId: string; identity: string };
1167
+ this.subscriptions.delete(producerId);
1051
1168
  this.preferredLayers.delete(producerId);
1052
1169
  this.receiveQualities.delete(producerId);
1053
1170
  this.remoteVideoLayers.delete(producerId);
1054
1171
  this.skippedVideos.delete(producerId);
1055
1172
  const consumer = this.consumers.get(producerId);
1056
- if (!consumer) break; // never consumed it, or already cleaned up
1057
- this.videoSlots.delete(producerId);
1058
- consumer.close();
1173
+ consumer?.close();
1059
1174
  this.consumers.delete(producerId);
1060
1175
  this.remoteSources.delete(producerId);
1061
1176
  this.remoteInfo.delete(producerId);
1062
- this.emit('trackEnded', { producerId, identity });
1177
+ if (consumer) this.emit('trackEnded', { producerId, identity });
1063
1178
  // A video slot opened under the fan-out limit: the oldest video left
1064
1179
  // waiting takes it.
1065
- if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
1180
+ this.releaseVideoSlot(producerId);
1066
1181
  break;
1067
1182
  }
1068
1183
  case 'producerPaused': {
@@ -1158,13 +1273,19 @@ export class StreamClient {
1158
1273
  // fresh one periodically so a reconnect at any point still works --
1159
1274
  // without this, any disconnect after the token's lifetime is terminal.
1160
1275
  const e = data as { token?: string; role?: Role; expiresAt?: number };
1161
- if (e.token) this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
1276
+ if (e.token) {
1277
+ this.serverTokenRevision += 1;
1278
+ this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
1279
+ }
1162
1280
  break;
1163
1281
  }
1164
1282
  case 'mustReconnect': {
1165
1283
  const e = data as { reason: string; token?: string; retry: boolean };
1166
1284
  // A promotion hands us a fresh token carrying the new role.
1167
- if (e.token) this.setToken(e.token, null);
1285
+ if (e.token) {
1286
+ this.serverTokenRevision += 1;
1287
+ this.setToken(e.token, null);
1288
+ }
1168
1289
  this.emit('mustReconnect', e);
1169
1290
  if (e.retry) {
1170
1291
  this.teardownMedia();
@@ -1190,6 +1311,10 @@ export class StreamClient {
1190
1311
  this.closedByUs = true;
1191
1312
  this.emit('roomClosed', d as never);
1192
1313
  this.finish('room-closed');
1314
+ // Before close() fails what is pending: an endRoom() this event
1315
+ // answers succeeded.
1316
+ for (const ended of this.endRoomWaiters) ended();
1317
+ this.endRoomWaiters.clear();
1193
1318
  this.close();
1194
1319
  break;
1195
1320
  case 'kicked':
@@ -1923,7 +2048,11 @@ export class StreamClient {
1923
2048
  if (this.screenStream === screen) await this.stopScreen('stopped');
1924
2049
  this.emit('error', err as Error);
1925
2050
  });
1926
- if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
2051
+ if (this.music) {
2052
+ const music = this.music;
2053
+ const operation = this.musicOperation;
2054
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
2055
+ }
1927
2056
  if (generation !== this.publishGeneration) return;
1928
2057
 
1929
2058
  let stream = this.localStream;
@@ -2134,26 +2263,41 @@ export class StreamClient {
2134
2263
  async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
2135
2264
  if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
2136
2265
  const generation = this.publishGeneration;
2137
- if (this.music) await this.stopMusic();
2138
- const music = new MusicSource(input, options);
2139
- try {
2140
- await this.produceMusic(music, generation);
2141
- // Checked again here: an unpublish() after the producer was kept has
2142
- // already closed it, and the music must not be remembered for a
2143
- // reconnect to publish again.
2144
- if (generation !== this.publishGeneration) throw unpublishedWhile('music');
2145
- } catch (err) {
2146
- music.close();
2147
- throw err;
2148
- }
2149
- this.music = music;
2150
- this.wasPublishing = true;
2151
- this.emitLocalMedia();
2266
+ // Taken now, in call order: a music publish still under way is over, and
2267
+ // a stopMusic() from here on ends this one.
2268
+ const operation = ++this.musicOperation;
2269
+ await this.musicTask(async () => {
2270
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2271
+ if (this.music) await this.closeMusic();
2272
+ const music = new MusicSource(input, options);
2273
+ try {
2274
+ await this.produceMusic(music, generation, operation);
2275
+ // Checked again here: an unpublish() or stopMusic() after the
2276
+ // producer was kept has already closed it, and the music must not be
2277
+ // remembered for a reconnect to publish again.
2278
+ if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
2279
+ } catch (err) {
2280
+ music.close();
2281
+ throw err;
2282
+ }
2283
+ this.music = music;
2284
+ this.wasPublishing = true;
2285
+ this.emitLocalMedia();
2286
+ });
2152
2287
  }
2153
2288
 
2154
- private async produceMusic(music: MusicSource, generation: number): Promise<void> {
2289
+ private musicTask<T>(task: () => Promise<T>): Promise<T> {
2290
+ const run = this.musicQueue.then(task, task);
2291
+ this.musicQueue = run.catch(() => undefined);
2292
+ return run;
2293
+ }
2294
+
2295
+ private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
2296
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
2297
+ // below, and wins.
2298
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
2155
2299
  const transport = await this.ensureSendTransport();
2156
- if (generation !== this.publishGeneration) throw unpublishedWhile('music');
2300
+ if (!current()) throw unpublishedWhile('music');
2157
2301
  const producer = await transport.produce({
2158
2302
  track: music.track,
2159
2303
  stopTracks: false,
@@ -2162,7 +2306,14 @@ export class StreamClient {
2162
2306
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
2163
2307
  appData: { source: 'music' },
2164
2308
  });
2165
- await this.keepProducer(producer, generation, 'music');
2309
+ if (!current()) {
2310
+ producer.close();
2311
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
2312
+ throw unpublishedWhile('music');
2313
+ }
2314
+ // Kept and named the music in one step, with no wait between: stopMusic()
2315
+ // finds the music producer by that name.
2316
+ this.producers.set(producer.id, producer);
2166
2317
  this.musicProducerId = producer.id;
2167
2318
  // Music switched off before a reconnect comes back paused here.
2168
2319
  await this.pauseOnServer(producer);
@@ -2193,8 +2344,18 @@ export class StreamClient {
2193
2344
  this.emitLocalMedia();
2194
2345
  }
2195
2346
 
2196
- /** Stop publishing music. The element or stream it came from is left as it was. */
2347
+ /**
2348
+ * Stop publishing music. The element or stream it came from is left as it
2349
+ * was. A publishMusic() still under way is stopped too: it rejects with
2350
+ * `unpublished` and publishes nothing.
2351
+ */
2197
2352
  async stopMusic(): Promise<void> {
2353
+ this.musicOperation += 1;
2354
+ await this.closeMusic();
2355
+ }
2356
+
2357
+ /** Close the music being published, here and at the server. */
2358
+ private async closeMusic(): Promise<void> {
2198
2359
  const id = this.musicProducerId;
2199
2360
  this.musicProducerId = null;
2200
2361
  this.music?.close();
@@ -2216,8 +2377,9 @@ export class StreamClient {
2216
2377
  }
2217
2378
 
2218
2379
  private async consume(remote: RemoteProducer): Promise<void> {
2380
+ if (this.closedByUs) return;
2219
2381
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2220
- if (this.consumers.has(remote.producerId)) return;
2382
+ if (this.subscriptions.has(remote.producerId)) return;
2221
2383
  // Your own camera is already local; pulling it back off the SFU wastes a
2222
2384
  // consumer and shows you a delayed copy of yourself.
2223
2385
  if (remote.identity === this.joinResult?.identity) return;
@@ -2236,34 +2398,67 @@ export class StreamClient {
2236
2398
  }
2237
2399
  this.videoSlots.add(remote.producerId);
2238
2400
  }
2401
+ const operation = {};
2402
+ this.subscriptions.set(remote.producerId, operation);
2239
2403
  try {
2240
- await this.consumeNow(remote);
2404
+ await this.consumeNow(remote, operation);
2241
2405
  } catch (err) {
2242
- if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
2406
+ // A closed producer or replaced session has already released its slot.
2407
+ // Its late failure must neither release a newer slot nor report a ghost error.
2408
+ if (this.subscriptions.get(remote.producerId) !== operation) return;
2409
+ this.subscriptions.delete(remote.producerId);
2410
+ this.consumers.get(remote.producerId)?.close();
2411
+ this.consumers.delete(remote.producerId);
2412
+ this.remoteVideoLayers.delete(remote.producerId);
2413
+ this.remoteSources.delete(remote.producerId);
2414
+ this.remoteInfo.delete(remote.producerId);
2415
+ this.releaseVideoSlot(remote.producerId);
2243
2416
  throw err;
2244
2417
  }
2245
2418
  }
2246
2419
 
2420
+ /**
2421
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2422
+ * freed it: an ended video, or a subscription that failed. Not while
2423
+ * reconnecting: a failure caused by the socket dropping would otherwise
2424
+ * start every waiting video on a dead connection, each failing with
2425
+ * not-connected; the reconnect's join lists them all again anyway.
2426
+ */
2427
+ private releaseVideoSlot(producerId: string): void {
2428
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
2429
+ }
2430
+
2247
2431
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
2248
- private async consumeSkipped(): Promise<void> {
2432
+ private consumeSkipped(): void {
2249
2433
  for (const [producerId, remote] of this.skippedVideos) {
2250
2434
  const limit = this.mediaPermissions.maxVideoSubscriptions;
2251
2435
  if (limit !== null && this.videoSlots.size >= limit) return;
2252
2436
  this.skippedVideos.delete(producerId);
2253
- await this.consume(remote);
2437
+ // consume reserves synchronously, before its first await. Consecutive
2438
+ // releases cannot admit the same producer or exceed the limit.
2439
+ void this.consume(remote).catch((err) => this.emit('error', err as Error));
2254
2440
  }
2255
2441
  }
2256
2442
 
2257
- private async consumeNow(remote: RemoteProducer): Promise<void> {
2443
+ private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
2258
2444
  if (!this.device) throw new RpcError('not-connected', 'not connected');
2445
+ const device = this.device;
2446
+ const current = (made?: Consumer) => {
2447
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
2448
+ made?.close();
2449
+ throw new RpcError('not-connected', 'subscription ended during setup');
2450
+ };
2259
2451
  const transport = await this.ensureRecvTransport();
2452
+ current();
2260
2453
  const params = (await this.rpc('consume', {
2261
2454
  transportId: transport.id,
2262
2455
  producerId: remote.producerId,
2263
- rtpCapabilities: this.device.rtpCapabilities,
2456
+ rtpCapabilities: device.rtpCapabilities,
2264
2457
  })) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
2265
2458
 
2459
+ current();
2266
2460
  const consumer = await transport.consume(params);
2461
+ current(consumer);
2267
2462
  this.consumers.set(remote.producerId, consumer);
2268
2463
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
2269
2464
 
@@ -2279,10 +2474,12 @@ export class StreamClient {
2279
2474
  if (layers) {
2280
2475
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
2281
2476
  }
2477
+ current(consumer);
2282
2478
 
2283
2479
  // The server created it paused. Resume only now that our end exists,
2284
2480
  // otherwise the first frames arrive before there is anything to decode them.
2285
2481
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2482
+ current(consumer);
2286
2483
 
2287
2484
  const kind = consumer.kind as 'audio' | 'video';
2288
2485
  const source = remote.source ?? defaultSource(kind);
@@ -2424,7 +2621,20 @@ export class StreamClient {
2424
2621
  }
2425
2622
  promote(identity: string, role: Role): Promise<unknown> { return this.rpc('promote', { identity, role }); }
2426
2623
  kick(identity: string, reason?: string): Promise<unknown> { return this.rpc('kick', { identity, reason }); }
2427
- endRoom(): Promise<unknown> { return this.rpc('endRoom'); }
2624
+ /**
2625
+ * End the room for everyone. The server tells the room it has closed
2626
+ * before it replies, and that roomClosed closes this session, so the reply
2627
+ * may never be read: roomClosed is then the answer, and endRoom() resolves
2628
+ * as the reply would have, with `{ ok: true }`. It still fails when the
2629
+ * server refuses, or the connection drops with neither.
2630
+ */
2631
+ endRoom(): Promise<unknown> {
2632
+ return new Promise((resolve, reject) => {
2633
+ const ended = () => resolve({ ok: true });
2634
+ this.endRoomWaiters.add(ended);
2635
+ this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
2636
+ });
2637
+ }
2428
2638
  getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
2429
2639
 
2430
2640
  // ------------------------------------------------------- slots and metadata