@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/dist/esm/index.js CHANGED
@@ -55,6 +55,8 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
55
55
  * otherwise starts low and takes many seconds to climb to the top layer.
56
56
  */
57
57
  const VIDEO_START_BITRATE_KBPS = 2000;
58
+ /** Why a connect() gave up: close() ran while it was under way, and close wins. */
59
+ const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
58
60
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
59
61
  const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
60
62
  /**
@@ -108,6 +110,16 @@ export class StreamClient {
108
110
  /** In-app music being published, kept across reconnects so it can be published again. */
109
111
  music = null;
110
112
  musicProducerId = null;
113
+ /**
114
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
115
+ * being made when it changes belongs to a publish that is over: it is
116
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
117
+ * music went out after stopMusic() had returned.
118
+ */
119
+ musicOperation = 0;
120
+ // Music publishes run one at a time: the server takes one producer per
121
+ // source, so the next is produced only once the last one is settled.
122
+ musicQueue = Promise.resolve();
111
123
  screenStream = null;
112
124
  screenMode = 'detail';
113
125
  screenAudioEnabled = true;
@@ -124,13 +136,23 @@ export class StreamClient {
124
136
  frameRateWarmupSamples = 2;
125
137
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
126
138
  remoteSources = new Map();
139
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
140
+ subscriptions = new Map();
127
141
  /**
128
142
  * The layers asked for each remote video, by producerId. A reconnect
129
143
  * consumes everything again, and the new consumer would otherwise start at
130
144
  * the full picture.
131
145
  */
132
146
  preferredLayers = new Map();
147
+ /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
148
+ endRoomWaiters = new Set();
133
149
  closedByUs = false;
150
+ /**
151
+ * Bumped when the session ends for good: close(), or finish(). A connect()
152
+ * under way when it did -- the app's, or a reconnect's -- sees the change
153
+ * after its next await and stops, so a closed session never comes back.
154
+ */
155
+ lifecycle = 0;
134
156
  reconnecting = false;
135
157
  reconnectAttempt = 0;
136
158
  /** Set once a connect() has joined and set up. Only such a session is reconnected. */
@@ -154,6 +176,17 @@ export class StreamClient {
154
176
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
155
177
  tokenExpiry;
156
178
  tokenTimer = null;
179
+ /**
180
+ * Bumped whenever the server hands this client a token (its own refresh, a
181
+ * promotion's). An updateToken() that started before then keeps the token
182
+ * it was given only if nothing newer arrived while it waited: its reply,
183
+ * or its fallback once the socket closed, used to replace a promotion's
184
+ * token, and the reconnect joined with the old one.
185
+ */
186
+ serverTokenRevision = 0;
187
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
188
+ tokenRequests = 0;
189
+ tokenRequestHeld = 0;
157
190
  /** Whose each received track is and whether it is switched off, by producerId. */
158
191
  remoteInfo = new Map();
159
192
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -211,9 +244,21 @@ export class StreamClient {
211
244
  /** Over for good: the state, then the event that says why. */
212
245
  finish(reason) {
213
246
  this.closedByUs = true;
247
+ this.lifecycle += 1;
214
248
  this.setState('closed');
215
249
  this.emit('closed', reason);
216
250
  }
251
+ /**
252
+ * Over for good without the app asking: finish, then release everything
253
+ * local as close() does -- camera, microphone, screen, music, recording.
254
+ * kicked and roomClosed always did; a replaced socket, a close without
255
+ * roomClosed and a reconnect that gave up left them capturing (and the
256
+ * recording uploading) after `closed`, until the app called close().
257
+ */
258
+ end(reason) {
259
+ this.finish(reason);
260
+ this.close();
261
+ }
217
262
  stopTimers() {
218
263
  if (this.tokenTimer)
219
264
  clearTimeout(this.tokenTimer);
@@ -235,21 +280,33 @@ export class StreamClient {
235
280
  }
236
281
  // ------------------------------------------------------------- connection
237
282
  async connect() {
283
+ const lifecycle = this.lifecycle;
284
+ const closed = () => lifecycle !== this.lifecycle;
238
285
  let result;
239
286
  if (this.state === 'idle' || this.state === 'closed')
240
287
  this.setState('connecting');
288
+ // The socket this call opens. A later connect() replaces it, and this
289
+ // call's failure must then leave the newer socket, and the state, alone.
290
+ let socket = null;
241
291
  try {
242
- await this.openSocket();
292
+ const opening = this.openSocket();
293
+ socket = this.ws;
294
+ await opening;
243
295
  result = (await this.rpc('join', { token: this.token }));
244
296
  this.joinResult = result;
245
297
  this.device = this.createDevice();
246
298
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
299
+ // close() ran while the Device loaded: going on would set the state
300
+ // back to connected, and emit joined, after closed.
301
+ if (closed())
302
+ throw closedWhileConnecting();
247
303
  }
248
304
  catch (err) {
249
305
  // A session that never got going: the app has the error. Left open,
250
306
  // the socket would be closed by the server's join deadline instead, and
251
307
  // that close would start reconnecting a session that never began.
252
- if (!this.joinedOnce) {
308
+ const superseded = this.ws !== null && this.ws !== socket;
309
+ if (!this.joinedOnce && !superseded) {
253
310
  this.detachSocket();
254
311
  if (this.state === 'connecting')
255
312
  this.setState('idle');
@@ -269,6 +326,8 @@ export class StreamClient {
269
326
  // decides what may return; reconnecting never turns a blocked device on.
270
327
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
271
328
  await this.unpublish();
329
+ if (closed())
330
+ throw closedWhileConnecting();
272
331
  }
273
332
  const own = result.participants.find((p) => p.identity === result.identity);
274
333
  if (own?.muted)
@@ -290,15 +349,22 @@ export class StreamClient {
290
349
  if (!present.has(producerId))
291
350
  this.remoteVideoLayers.delete(producerId);
292
351
  // Subscribe to everything already publishing before we announce readiness.
352
+ // A close() part-way stops it: what is left would only fail, after closed.
293
353
  for (const p of result.producers) {
294
- await this.consume(p).catch((err) => this.emit('error', err));
354
+ if (closed())
355
+ throw closedWhileConnecting();
356
+ await this.consume(p).catch((err) => { if (!closed())
357
+ this.emit('error', err); });
295
358
  }
296
359
  // Restore our own broadcast. This is what makes an origin failover
297
360
  // survivable for the host: it reconnects onto the new origin and the
298
361
  // stream comes back on its own.
299
362
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
300
- await this.republish().catch((err) => this.emit('error', err));
363
+ await this.republish().catch((err) => { if (!closed())
364
+ this.emit('error', err); });
301
365
  }
366
+ if (closed())
367
+ throw closedWhileConnecting();
302
368
  this.syncNetworkWatch();
303
369
  return result;
304
370
  }
@@ -344,17 +410,16 @@ export class StreamClient {
344
410
  // Another session for this identity is live. Reconnecting would
345
411
  // displace it, and it would displace us right back, forever.
346
412
  this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
347
- // The producers outlive this close until the app calls close(); the
348
- // videoLimited sampling must not, or it reports after `closed`.
413
+ // The videoLimited sampling stops first, or it reports after `closed`.
349
414
  this.limitWatch.reset();
350
- this.finish('replaced');
415
+ this.end('replaced');
351
416
  return;
352
417
  }
353
418
  if (ev.code === CLOSE_ENDED) {
354
419
  // Normally roomClosed has already ended the session; this covers a
355
420
  // close that arrived without it.
356
421
  this.limitWatch.reset();
357
- this.finish('room-closed');
422
+ this.end('room-closed');
358
423
  return;
359
424
  }
360
425
  // Before a first successful join the app is still waiting on
@@ -367,17 +432,33 @@ export class StreamClient {
367
432
  });
368
433
  });
369
434
  }
370
- detachSocket() {
435
+ detachSocket(reason = 'superseded', why = 'the connection was replaced') {
371
436
  const ws = this.ws;
372
437
  this.ws = null;
373
- if (ws && ws.readyState <= WebSocket.OPEN) {
438
+ if (!ws)
439
+ return;
440
+ if (ws.readyState <= WebSocket.OPEN) {
374
441
  try {
375
- ws.close(1000, 'superseded');
442
+ ws.close(1000, reason);
376
443
  }
377
444
  catch {
378
445
  /* already closing */
379
446
  }
380
447
  }
448
+ // Its replies are no longer read, so what waits on it would only wait out
449
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
450
+ // server may already have acted on it.
451
+ this.failPending(ws, new RpcError('not-connected', why));
452
+ }
453
+ /**
454
+ * A reconnect that ends the session lets go of its socket. A refused join
455
+ * leaves that socket open (connect() keeps it once a session has joined),
456
+ * and the session would otherwise report closed with it still attached and
457
+ * still sending.
458
+ */
459
+ releaseSocket() {
460
+ this.detachSocket('gave up', 'the session has ended');
461
+ this.failAllPending(new RpcError('not-connected', 'the session has ended'));
381
462
  }
382
463
  /**
383
464
  * Reconnection is mandatory, not optional: nodes get drained, relays die and
@@ -396,7 +477,8 @@ export class StreamClient {
396
477
  this.reconnectAttempt += 1;
397
478
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
398
479
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
399
- this.finish('gave-up');
480
+ this.releaseSocket();
481
+ this.end('gave-up');
400
482
  return;
401
483
  }
402
484
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -414,12 +496,17 @@ export class StreamClient {
414
496
  return;
415
497
  }
416
498
  catch (err) {
499
+ // Ended while this attempt was under way -- by close(), or by the
500
+ // server -- and that has been reported: nothing follows closed.
501
+ if (this.closedByUs)
502
+ return;
417
503
  this.emit('error', err);
418
504
  // Stop on failures a retry cannot resolve (the registry's terminal
419
505
  // codes). Hammering a room that has ended produces one error per
420
506
  // attempt forever and drowns out whatever actually went wrong.
421
507
  if (err instanceof RpcError && isTerminalError(err.code)) {
422
- this.finish('gave-up');
508
+ this.releaseSocket();
509
+ this.end('gave-up');
423
510
  return;
424
511
  }
425
512
  }
@@ -435,6 +522,7 @@ export class StreamClient {
435
522
  // others have already reported themselves and set closedByUs.
436
523
  const wasOpen = !this.closedByUs;
437
524
  this.closedByUs = true;
525
+ this.lifecycle += 1;
438
526
  this.publishGeneration += 1;
439
527
  this.wasPublishing = false;
440
528
  this.teardownMedia();
@@ -454,11 +542,16 @@ export class StreamClient {
454
542
  const ws = this.ws;
455
543
  this.ws = null;
456
544
  ws?.close(1000, 'client closed');
545
+ // Detached first, so the socket's own close handler no longer fails what
546
+ // was waiting on it: done here, or a join or a request in flight would
547
+ // wait out its 15 s timeout.
548
+ this.failAllPending(new RpcError('not-connected', 'the session was closed'));
457
549
  this.setState('closed');
458
550
  if (wasOpen)
459
551
  this.emit('closed', 'left');
460
552
  }
461
553
  teardownMedia() {
554
+ this.subscriptions.clear();
462
555
  for (const c of this.consumers.values())
463
556
  c.close();
464
557
  this.consumers.clear();
@@ -529,19 +622,29 @@ export class StreamClient {
529
622
  * `tokenExpiring`. On a live connection the server checks it like a join
530
623
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
531
624
  * session then keeps its token); otherwise it is kept for the next join,
532
- * which checks it there. Resolves to when it expires (unix ms), when known.
625
+ * which checks it there. Resolves to when the token now held expires (unix
626
+ * ms), when known. A newer token that arrives while this waits is kept
627
+ * instead of this one: the server's (its own refresh, or a promotion's,
628
+ * which carries the new role) or a later updateToken()'s.
533
629
  */
534
630
  async updateToken(token) {
535
631
  if (typeof token !== 'string' || token.trim() === '')
536
632
  throw new TypeError('updateToken needs a token');
537
633
  if (this.state === 'closed')
538
634
  throw new RpcError('not-connected', 'this session is closed; create a new client');
635
+ const request = ++this.tokenRequests;
636
+ const revision = this.serverTokenRevision;
637
+ const keep = (expiresAt) => {
638
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
639
+ this.tokenRequestHeld = request;
640
+ this.setToken(token, expiresAt);
641
+ }
642
+ return { expiresAt: this.tokenExpiry };
643
+ };
539
644
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
540
645
  try {
541
646
  const res = (await this.rpc('refreshToken', { token }));
542
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
543
- this.setToken(token, at);
544
- return { expiresAt: this.tokenExpiry };
647
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
545
648
  }
546
649
  catch (err) {
547
650
  // An older server has no refreshToken, and a socket that dropped
@@ -551,8 +654,7 @@ export class StreamClient {
551
654
  throw err;
552
655
  }
553
656
  }
554
- this.setToken(token, null);
555
- return { expiresAt: this.tokenExpiry };
657
+ return keep(null);
556
658
  }
557
659
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
558
660
  setToken(token, expiresAt) {
@@ -613,10 +715,20 @@ export class StreamClient {
613
715
  this.pending.delete(id);
614
716
  reject(new RpcError('timeout', `${method} timed out`));
615
717
  }, RPC_TIMEOUT_MS);
616
- this.pending.set(id, { resolve, reject, timer });
718
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
617
719
  this.ws.send(JSON.stringify({ id, method, params }));
618
720
  });
619
721
  }
722
+ /** Fail what was sent on `ws` and is still waiting. */
723
+ failPending(ws, err) {
724
+ for (const [id, p] of this.pending) {
725
+ if (p.ws !== ws)
726
+ continue;
727
+ clearTimeout(p.timer);
728
+ this.pending.delete(id);
729
+ p.reject(err);
730
+ }
731
+ }
620
732
  failAllPending(err) {
621
733
  for (const p of this.pending.values()) {
622
734
  clearTimeout(p.timer);
@@ -632,6 +744,10 @@ export class StreamClient {
632
744
  catch {
633
745
  return;
634
746
  }
747
+ // Valid JSON that is not a message object ("null" would throw below) is
748
+ // ignored like any other frame that is not a message.
749
+ if (typeof msg !== 'object' || msg === null)
750
+ return;
635
751
  if (typeof msg.id === 'number') {
636
752
  const p = this.pending.get(msg.id);
637
753
  if (!p)
@@ -657,23 +773,21 @@ export class StreamClient {
657
773
  break;
658
774
  case 'producerClosed': {
659
775
  const { producerId, identity } = data;
776
+ this.subscriptions.delete(producerId);
660
777
  this.preferredLayers.delete(producerId);
661
778
  this.receiveQualities.delete(producerId);
662
779
  this.remoteVideoLayers.delete(producerId);
663
780
  this.skippedVideos.delete(producerId);
664
781
  const consumer = this.consumers.get(producerId);
665
- if (!consumer)
666
- break; // never consumed it, or already cleaned up
667
- this.videoSlots.delete(producerId);
668
- consumer.close();
782
+ consumer?.close();
669
783
  this.consumers.delete(producerId);
670
784
  this.remoteSources.delete(producerId);
671
785
  this.remoteInfo.delete(producerId);
672
- this.emit('trackEnded', { producerId, identity });
786
+ if (consumer)
787
+ this.emit('trackEnded', { producerId, identity });
673
788
  // A video slot opened under the fan-out limit: the oldest video left
674
789
  // waiting takes it.
675
- if (consumer.kind === 'video')
676
- void this.consumeSkipped().catch((err) => this.emit('error', err));
790
+ this.releaseVideoSlot(producerId);
677
791
  break;
678
792
  }
679
793
  case 'producerPaused': {
@@ -771,15 +885,19 @@ export class StreamClient {
771
885
  // fresh one periodically so a reconnect at any point still works --
772
886
  // without this, any disconnect after the token's lifetime is terminal.
773
887
  const e = data;
774
- if (e.token)
888
+ if (e.token) {
889
+ this.serverTokenRevision += 1;
775
890
  this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
891
+ }
776
892
  break;
777
893
  }
778
894
  case 'mustReconnect': {
779
895
  const e = data;
780
896
  // A promotion hands us a fresh token carrying the new role.
781
- if (e.token)
897
+ if (e.token) {
898
+ this.serverTokenRevision += 1;
782
899
  this.setToken(e.token, null);
900
+ }
783
901
  this.emit('mustReconnect', e);
784
902
  if (e.retry) {
785
903
  this.teardownMedia();
@@ -805,6 +923,11 @@ export class StreamClient {
805
923
  this.closedByUs = true;
806
924
  this.emit('roomClosed', d);
807
925
  this.finish('room-closed');
926
+ // Before close() fails what is pending: an endRoom() this event
927
+ // answers succeeded.
928
+ for (const ended of this.endRoomWaiters)
929
+ ended();
930
+ this.endRoomWaiters.clear();
808
931
  this.close();
809
932
  break;
810
933
  case 'kicked':
@@ -1538,8 +1661,11 @@ export class StreamClient {
1538
1661
  await this.stopScreen('stopped');
1539
1662
  this.emit('error', err);
1540
1663
  });
1541
- if (this.music)
1542
- await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1664
+ if (this.music) {
1665
+ const music = this.music;
1666
+ const operation = this.musicOperation;
1667
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
1668
+ }
1543
1669
  if (generation !== this.publishGeneration)
1544
1670
  return;
1545
1671
  let stream = this.localStream;
@@ -1773,28 +1899,43 @@ export class StreamClient {
1773
1899
  if (!this.mediaPermissions.audio)
1774
1900
  throw new RpcError('media-disabled', 'audio is switched off for this project');
1775
1901
  const generation = this.publishGeneration;
1776
- if (this.music)
1777
- await this.stopMusic();
1778
- const music = new MusicSource(input, options);
1779
- try {
1780
- await this.produceMusic(music, generation);
1781
- // Checked again here: an unpublish() after the producer was kept has
1782
- // already closed it, and the music must not be remembered for a
1783
- // reconnect to publish again.
1784
- if (generation !== this.publishGeneration)
1902
+ // Taken now, in call order: a music publish still under way is over, and
1903
+ // a stopMusic() from here on ends this one.
1904
+ const operation = ++this.musicOperation;
1905
+ await this.musicTask(async () => {
1906
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1785
1907
  throw unpublishedWhile('music');
1786
- }
1787
- catch (err) {
1788
- music.close();
1789
- throw err;
1790
- }
1791
- this.music = music;
1792
- this.wasPublishing = true;
1793
- this.emitLocalMedia();
1908
+ if (this.music)
1909
+ await this.closeMusic();
1910
+ const music = new MusicSource(input, options);
1911
+ try {
1912
+ await this.produceMusic(music, generation, operation);
1913
+ // Checked again here: an unpublish() or stopMusic() after the
1914
+ // producer was kept has already closed it, and the music must not be
1915
+ // remembered for a reconnect to publish again.
1916
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1917
+ throw unpublishedWhile('music');
1918
+ }
1919
+ catch (err) {
1920
+ music.close();
1921
+ throw err;
1922
+ }
1923
+ this.music = music;
1924
+ this.wasPublishing = true;
1925
+ this.emitLocalMedia();
1926
+ });
1794
1927
  }
1795
- async produceMusic(music, generation) {
1928
+ musicTask(task) {
1929
+ const run = this.musicQueue.then(task, task);
1930
+ this.musicQueue = run.catch(() => undefined);
1931
+ return run;
1932
+ }
1933
+ async produceMusic(music, generation, operation) {
1934
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
1935
+ // below, and wins.
1936
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
1796
1937
  const transport = await this.ensureSendTransport();
1797
- if (generation !== this.publishGeneration)
1938
+ if (!current())
1798
1939
  throw unpublishedWhile('music');
1799
1940
  const producer = await transport.produce({
1800
1941
  track: music.track,
@@ -1804,7 +1945,14 @@ export class StreamClient {
1804
1945
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
1805
1946
  appData: { source: 'music' },
1806
1947
  });
1807
- await this.keepProducer(producer, generation, 'music');
1948
+ if (!current()) {
1949
+ producer.close();
1950
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1951
+ throw unpublishedWhile('music');
1952
+ }
1953
+ // Kept and named the music in one step, with no wait between: stopMusic()
1954
+ // finds the music producer by that name.
1955
+ this.producers.set(producer.id, producer);
1808
1956
  this.musicProducerId = producer.id;
1809
1957
  // Music switched off before a reconnect comes back paused here.
1810
1958
  await this.pauseOnServer(producer);
@@ -1833,8 +1981,17 @@ export class StreamClient {
1833
1981
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1834
1982
  this.emitLocalMedia();
1835
1983
  }
1836
- /** Stop publishing music. The element or stream it came from is left as it was. */
1984
+ /**
1985
+ * Stop publishing music. The element or stream it came from is left as it
1986
+ * was. A publishMusic() still under way is stopped too: it rejects with
1987
+ * `unpublished` and publishes nothing.
1988
+ */
1837
1989
  async stopMusic() {
1990
+ this.musicOperation += 1;
1991
+ await this.closeMusic();
1992
+ }
1993
+ /** Close the music being published, here and at the server. */
1994
+ async closeMusic() {
1838
1995
  const id = this.musicProducerId;
1839
1996
  this.musicProducerId = null;
1840
1997
  this.music?.close();
@@ -1854,9 +2011,11 @@ export class StreamClient {
1854
2011
  });
1855
2012
  }
1856
2013
  async consume(remote) {
2014
+ if (this.closedByUs)
2015
+ return;
1857
2016
  if (!this.device)
1858
2017
  throw new RpcError('not-connected', 'not connected');
1859
- if (this.consumers.has(remote.producerId))
2018
+ if (this.subscriptions.has(remote.producerId))
1860
2019
  return;
1861
2020
  // Your own camera is already local; pulling it back off the SFU wastes a
1862
2021
  // consumer and shows you a delayed copy of yourself.
@@ -1876,35 +2035,69 @@ export class StreamClient {
1876
2035
  }
1877
2036
  this.videoSlots.add(remote.producerId);
1878
2037
  }
2038
+ const operation = {};
2039
+ this.subscriptions.set(remote.producerId, operation);
1879
2040
  try {
1880
- await this.consumeNow(remote);
2041
+ await this.consumeNow(remote, operation);
1881
2042
  }
1882
2043
  catch (err) {
1883
- if (!this.consumers.has(remote.producerId))
1884
- this.videoSlots.delete(remote.producerId);
2044
+ // A closed producer or replaced session has already released its slot.
2045
+ // Its late failure must neither release a newer slot nor report a ghost error.
2046
+ if (this.subscriptions.get(remote.producerId) !== operation)
2047
+ return;
2048
+ this.subscriptions.delete(remote.producerId);
2049
+ this.consumers.get(remote.producerId)?.close();
2050
+ this.consumers.delete(remote.producerId);
2051
+ this.remoteVideoLayers.delete(remote.producerId);
2052
+ this.remoteSources.delete(remote.producerId);
2053
+ this.remoteInfo.delete(remote.producerId);
2054
+ this.releaseVideoSlot(remote.producerId);
1885
2055
  throw err;
1886
2056
  }
1887
2057
  }
2058
+ /**
2059
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2060
+ * freed it: an ended video, or a subscription that failed. Not while
2061
+ * reconnecting: a failure caused by the socket dropping would otherwise
2062
+ * start every waiting video on a dead connection, each failing with
2063
+ * not-connected; the reconnect's join lists them all again anyway.
2064
+ */
2065
+ releaseVideoSlot(producerId) {
2066
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
2067
+ this.consumeSkipped();
2068
+ }
1888
2069
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1889
- async consumeSkipped() {
2070
+ consumeSkipped() {
1890
2071
  for (const [producerId, remote] of this.skippedVideos) {
1891
2072
  const limit = this.mediaPermissions.maxVideoSubscriptions;
1892
2073
  if (limit !== null && this.videoSlots.size >= limit)
1893
2074
  return;
1894
2075
  this.skippedVideos.delete(producerId);
1895
- await this.consume(remote);
2076
+ // consume reserves synchronously, before its first await. Consecutive
2077
+ // releases cannot admit the same producer or exceed the limit.
2078
+ void this.consume(remote).catch((err) => this.emit('error', err));
1896
2079
  }
1897
2080
  }
1898
- async consumeNow(remote) {
2081
+ async consumeNow(remote, operation) {
1899
2082
  if (!this.device)
1900
2083
  throw new RpcError('not-connected', 'not connected');
2084
+ const device = this.device;
2085
+ const current = (made) => {
2086
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
2087
+ return;
2088
+ made?.close();
2089
+ throw new RpcError('not-connected', 'subscription ended during setup');
2090
+ };
1901
2091
  const transport = await this.ensureRecvTransport();
2092
+ current();
1902
2093
  const params = (await this.rpc('consume', {
1903
2094
  transportId: transport.id,
1904
2095
  producerId: remote.producerId,
1905
- rtpCapabilities: this.device.rtpCapabilities,
2096
+ rtpCapabilities: device.rtpCapabilities,
1906
2097
  }));
2098
+ current();
1907
2099
  const consumer = await transport.consume(params);
2100
+ current(consumer);
1908
2101
  this.consumers.set(remote.producerId, consumer);
1909
2102
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1910
2103
  // Layers asked for before a reconnect: the new consumer gets them before
@@ -1924,9 +2117,11 @@ export class StreamClient {
1924
2117
  if (layers) {
1925
2118
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1926
2119
  }
2120
+ current(consumer);
1927
2121
  // The server created it paused. Resume only now that our end exists,
1928
2122
  // otherwise the first frames arrive before there is anything to decode them.
1929
2123
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2124
+ current(consumer);
1930
2125
  const kind = consumer.kind;
1931
2126
  const source = remote.source ?? defaultSource(kind);
1932
2127
  this.remoteSources.set(remote.producerId, source);
@@ -2073,7 +2268,20 @@ export class StreamClient {
2073
2268
  }
2074
2269
  promote(identity, role) { return this.rpc('promote', { identity, role }); }
2075
2270
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
2076
- endRoom() { return this.rpc('endRoom'); }
2271
+ /**
2272
+ * End the room for everyone. The server tells the room it has closed
2273
+ * before it replies, and that roomClosed closes this session, so the reply
2274
+ * may never be read: roomClosed is then the answer, and endRoom() resolves
2275
+ * as the reply would have, with `{ ok: true }`. It still fails when the
2276
+ * server refuses, or the connection drops with neither.
2277
+ */
2278
+ endRoom() {
2279
+ return new Promise((resolve, reject) => {
2280
+ const ended = () => resolve({ ok: true });
2281
+ this.endRoomWaiters.add(ended);
2282
+ this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
2283
+ });
2284
+ }
2077
2285
  getRoomState() { return this.rpc('getRoomState'); }
2078
2286
  // ------------------------------------------------------- slots and metadata
2079
2287
  //