@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/cjs/index.js CHANGED
@@ -86,6 +86,8 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
86
86
  * otherwise starts low and takes many seconds to climb to the top layer.
87
87
  */
88
88
  const VIDEO_START_BITRATE_KBPS = 2000;
89
+ /** Why a connect() gave up: close() ran while it was under way, and close wins. */
90
+ const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
89
91
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
90
92
  const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
91
93
  /**
@@ -139,6 +141,16 @@ class StreamClient {
139
141
  /** In-app music being published, kept across reconnects so it can be published again. */
140
142
  music = null;
141
143
  musicProducerId = null;
144
+ /**
145
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
146
+ * being made when it changes belongs to a publish that is over: it is
147
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
148
+ * music went out after stopMusic() had returned.
149
+ */
150
+ musicOperation = 0;
151
+ // Music publishes run one at a time: the server takes one producer per
152
+ // source, so the next is produced only once the last one is settled.
153
+ musicQueue = Promise.resolve();
142
154
  screenStream = null;
143
155
  screenMode = 'detail';
144
156
  screenAudioEnabled = true;
@@ -155,13 +167,23 @@ class StreamClient {
155
167
  frameRateWarmupSamples = 2;
156
168
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
157
169
  remoteSources = new Map();
170
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
171
+ subscriptions = new Map();
158
172
  /**
159
173
  * The layers asked for each remote video, by producerId. A reconnect
160
174
  * consumes everything again, and the new consumer would otherwise start at
161
175
  * the full picture.
162
176
  */
163
177
  preferredLayers = new Map();
178
+ /** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
179
+ endRoomWaiters = new Set();
164
180
  closedByUs = false;
181
+ /**
182
+ * Bumped when the session ends for good: close(), or finish(). A connect()
183
+ * under way when it did -- the app's, or a reconnect's -- sees the change
184
+ * after its next await and stops, so a closed session never comes back.
185
+ */
186
+ lifecycle = 0;
165
187
  reconnecting = false;
166
188
  reconnectAttempt = 0;
167
189
  /** Set once a connect() has joined and set up. Only such a session is reconnected. */
@@ -185,6 +207,17 @@ class StreamClient {
185
207
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
186
208
  tokenExpiry;
187
209
  tokenTimer = null;
210
+ /**
211
+ * Bumped whenever the server hands this client a token (its own refresh, a
212
+ * promotion's). An updateToken() that started before then keeps the token
213
+ * it was given only if nothing newer arrived while it waited: its reply,
214
+ * or its fallback once the socket closed, used to replace a promotion's
215
+ * token, and the reconnect joined with the old one.
216
+ */
217
+ serverTokenRevision = 0;
218
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
219
+ tokenRequests = 0;
220
+ tokenRequestHeld = 0;
188
221
  /** Whose each received track is and whether it is switched off, by producerId. */
189
222
  remoteInfo = new Map();
190
223
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -242,9 +275,21 @@ class StreamClient {
242
275
  /** Over for good: the state, then the event that says why. */
243
276
  finish(reason) {
244
277
  this.closedByUs = true;
278
+ this.lifecycle += 1;
245
279
  this.setState('closed');
246
280
  this.emit('closed', reason);
247
281
  }
282
+ /**
283
+ * Over for good without the app asking: finish, then release everything
284
+ * local as close() does -- camera, microphone, screen, music, recording.
285
+ * kicked and roomClosed always did; a replaced socket, a close without
286
+ * roomClosed and a reconnect that gave up left them capturing (and the
287
+ * recording uploading) after `closed`, until the app called close().
288
+ */
289
+ end(reason) {
290
+ this.finish(reason);
291
+ this.close();
292
+ }
248
293
  stopTimers() {
249
294
  if (this.tokenTimer)
250
295
  clearTimeout(this.tokenTimer);
@@ -266,21 +311,33 @@ class StreamClient {
266
311
  }
267
312
  // ------------------------------------------------------------- connection
268
313
  async connect() {
314
+ const lifecycle = this.lifecycle;
315
+ const closed = () => lifecycle !== this.lifecycle;
269
316
  let result;
270
317
  if (this.state === 'idle' || this.state === 'closed')
271
318
  this.setState('connecting');
319
+ // The socket this call opens. A later connect() replaces it, and this
320
+ // call's failure must then leave the newer socket, and the state, alone.
321
+ let socket = null;
272
322
  try {
273
- await this.openSocket();
323
+ const opening = this.openSocket();
324
+ socket = this.ws;
325
+ await opening;
274
326
  result = (await this.rpc('join', { token: this.token }));
275
327
  this.joinResult = result;
276
328
  this.device = this.createDevice();
277
329
  await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
330
+ // close() ran while the Device loaded: going on would set the state
331
+ // back to connected, and emit joined, after closed.
332
+ if (closed())
333
+ throw closedWhileConnecting();
278
334
  }
279
335
  catch (err) {
280
336
  // A session that never got going: the app has the error. Left open,
281
337
  // the socket would be closed by the server's join deadline instead, and
282
338
  // that close would start reconnecting a session that never began.
283
- if (!this.joinedOnce) {
339
+ const superseded = this.ws !== null && this.ws !== socket;
340
+ if (!this.joinedOnce && !superseded) {
284
341
  this.detachSocket();
285
342
  if (this.state === 'connecting')
286
343
  this.setState('idle');
@@ -300,6 +357,8 @@ class StreamClient {
300
357
  // decides what may return; reconnecting never turns a blocked device on.
301
358
  if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
302
359
  await this.unpublish();
360
+ if (closed())
361
+ throw closedWhileConnecting();
303
362
  }
304
363
  const own = result.participants.find((p) => p.identity === result.identity);
305
364
  if (own?.muted)
@@ -321,15 +380,22 @@ class StreamClient {
321
380
  if (!present.has(producerId))
322
381
  this.remoteVideoLayers.delete(producerId);
323
382
  // Subscribe to everything already publishing before we announce readiness.
383
+ // A close() part-way stops it: what is left would only fail, after closed.
324
384
  for (const p of result.producers) {
325
- await this.consume(p).catch((err) => this.emit('error', err));
385
+ if (closed())
386
+ throw closedWhileConnecting();
387
+ await this.consume(p).catch((err) => { if (!closed())
388
+ this.emit('error', err); });
326
389
  }
327
390
  // Restore our own broadcast. This is what makes an origin failover
328
391
  // survivable for the host: it reconnects onto the new origin and the
329
392
  // stream comes back on its own.
330
393
  if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
331
- await this.republish().catch((err) => this.emit('error', err));
394
+ await this.republish().catch((err) => { if (!closed())
395
+ this.emit('error', err); });
332
396
  }
397
+ if (closed())
398
+ throw closedWhileConnecting();
333
399
  this.syncNetworkWatch();
334
400
  return result;
335
401
  }
@@ -375,17 +441,16 @@ class StreamClient {
375
441
  // Another session for this identity is live. Reconnecting would
376
442
  // displace it, and it would displace us right back, forever.
377
443
  this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
378
- // The producers outlive this close until the app calls close(); the
379
- // videoLimited sampling must not, or it reports after `closed`.
444
+ // The videoLimited sampling stops first, or it reports after `closed`.
380
445
  this.limitWatch.reset();
381
- this.finish('replaced');
446
+ this.end('replaced');
382
447
  return;
383
448
  }
384
449
  if (ev.code === CLOSE_ENDED) {
385
450
  // Normally roomClosed has already ended the session; this covers a
386
451
  // close that arrived without it.
387
452
  this.limitWatch.reset();
388
- this.finish('room-closed');
453
+ this.end('room-closed');
389
454
  return;
390
455
  }
391
456
  // Before a first successful join the app is still waiting on
@@ -398,17 +463,33 @@ class StreamClient {
398
463
  });
399
464
  });
400
465
  }
401
- detachSocket() {
466
+ detachSocket(reason = 'superseded', why = 'the connection was replaced') {
402
467
  const ws = this.ws;
403
468
  this.ws = null;
404
- if (ws && ws.readyState <= WebSocket.OPEN) {
469
+ if (!ws)
470
+ return;
471
+ if (ws.readyState <= WebSocket.OPEN) {
405
472
  try {
406
- ws.close(1000, 'superseded');
473
+ ws.close(1000, reason);
407
474
  }
408
475
  catch {
409
476
  /* already closing */
410
477
  }
411
478
  }
479
+ // Its replies are no longer read, so what waits on it would only wait out
480
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
481
+ // server may already have acted on it.
482
+ this.failPending(ws, new errors_js_1.RpcError('not-connected', why));
483
+ }
484
+ /**
485
+ * A reconnect that ends the session lets go of its socket. A refused join
486
+ * leaves that socket open (connect() keeps it once a session has joined),
487
+ * and the session would otherwise report closed with it still attached and
488
+ * still sending.
489
+ */
490
+ releaseSocket() {
491
+ this.detachSocket('gave up', 'the session has ended');
492
+ this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
412
493
  }
413
494
  /**
414
495
  * Reconnection is mandatory, not optional: nodes get drained, relays die and
@@ -427,7 +508,8 @@ class StreamClient {
427
508
  this.reconnectAttempt += 1;
428
509
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
429
510
  this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
430
- this.finish('gave-up');
511
+ this.releaseSocket();
512
+ this.end('gave-up');
431
513
  return;
432
514
  }
433
515
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -445,12 +527,17 @@ class StreamClient {
445
527
  return;
446
528
  }
447
529
  catch (err) {
530
+ // Ended while this attempt was under way -- by close(), or by the
531
+ // server -- and that has been reported: nothing follows closed.
532
+ if (this.closedByUs)
533
+ return;
448
534
  this.emit('error', err);
449
535
  // Stop on failures a retry cannot resolve (the registry's terminal
450
536
  // codes). Hammering a room that has ended produces one error per
451
537
  // attempt forever and drowns out whatever actually went wrong.
452
538
  if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
453
- this.finish('gave-up');
539
+ this.releaseSocket();
540
+ this.end('gave-up');
454
541
  return;
455
542
  }
456
543
  }
@@ -466,6 +553,7 @@ class StreamClient {
466
553
  // others have already reported themselves and set closedByUs.
467
554
  const wasOpen = !this.closedByUs;
468
555
  this.closedByUs = true;
556
+ this.lifecycle += 1;
469
557
  this.publishGeneration += 1;
470
558
  this.wasPublishing = false;
471
559
  this.teardownMedia();
@@ -485,11 +573,16 @@ class StreamClient {
485
573
  const ws = this.ws;
486
574
  this.ws = null;
487
575
  ws?.close(1000, 'client closed');
576
+ // Detached first, so the socket's own close handler no longer fails what
577
+ // was waiting on it: done here, or a join or a request in flight would
578
+ // wait out its 15 s timeout.
579
+ this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session was closed'));
488
580
  this.setState('closed');
489
581
  if (wasOpen)
490
582
  this.emit('closed', 'left');
491
583
  }
492
584
  teardownMedia() {
585
+ this.subscriptions.clear();
493
586
  for (const c of this.consumers.values())
494
587
  c.close();
495
588
  this.consumers.clear();
@@ -560,19 +653,29 @@ class StreamClient {
560
653
  * `tokenExpiring`. On a live connection the server checks it like a join
561
654
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
562
655
  * session then keeps its token); otherwise it is kept for the next join,
563
- * which checks it there. Resolves to when it expires (unix ms), when known.
656
+ * which checks it there. Resolves to when the token now held expires (unix
657
+ * ms), when known. A newer token that arrives while this waits is kept
658
+ * instead of this one: the server's (its own refresh, or a promotion's,
659
+ * which carries the new role) or a later updateToken()'s.
564
660
  */
565
661
  async updateToken(token) {
566
662
  if (typeof token !== 'string' || token.trim() === '')
567
663
  throw new TypeError('updateToken needs a token');
568
664
  if (this.state === 'closed')
569
665
  throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
666
+ const request = ++this.tokenRequests;
667
+ const revision = this.serverTokenRevision;
668
+ const keep = (expiresAt) => {
669
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
670
+ this.tokenRequestHeld = request;
671
+ this.setToken(token, expiresAt);
672
+ }
673
+ return { expiresAt: this.tokenExpiry };
674
+ };
570
675
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
571
676
  try {
572
677
  const res = (await this.rpc('refreshToken', { token }));
573
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
574
- this.setToken(token, at);
575
- return { expiresAt: this.tokenExpiry };
678
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
576
679
  }
577
680
  catch (err) {
578
681
  // An older server has no refreshToken, and a socket that dropped
@@ -582,8 +685,7 @@ class StreamClient {
582
685
  throw err;
583
686
  }
584
687
  }
585
- this.setToken(token, null);
586
- return { expiresAt: this.tokenExpiry };
688
+ return keep(null);
587
689
  }
588
690
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
589
691
  setToken(token, expiresAt) {
@@ -644,10 +746,20 @@ class StreamClient {
644
746
  this.pending.delete(id);
645
747
  reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
646
748
  }, RPC_TIMEOUT_MS);
647
- this.pending.set(id, { resolve, reject, timer });
749
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
648
750
  this.ws.send(JSON.stringify({ id, method, params }));
649
751
  });
650
752
  }
753
+ /** Fail what was sent on `ws` and is still waiting. */
754
+ failPending(ws, err) {
755
+ for (const [id, p] of this.pending) {
756
+ if (p.ws !== ws)
757
+ continue;
758
+ clearTimeout(p.timer);
759
+ this.pending.delete(id);
760
+ p.reject(err);
761
+ }
762
+ }
651
763
  failAllPending(err) {
652
764
  for (const p of this.pending.values()) {
653
765
  clearTimeout(p.timer);
@@ -663,6 +775,10 @@ class StreamClient {
663
775
  catch {
664
776
  return;
665
777
  }
778
+ // Valid JSON that is not a message object ("null" would throw below) is
779
+ // ignored like any other frame that is not a message.
780
+ if (typeof msg !== 'object' || msg === null)
781
+ return;
666
782
  if (typeof msg.id === 'number') {
667
783
  const p = this.pending.get(msg.id);
668
784
  if (!p)
@@ -688,23 +804,21 @@ class StreamClient {
688
804
  break;
689
805
  case 'producerClosed': {
690
806
  const { producerId, identity } = data;
807
+ this.subscriptions.delete(producerId);
691
808
  this.preferredLayers.delete(producerId);
692
809
  this.receiveQualities.delete(producerId);
693
810
  this.remoteVideoLayers.delete(producerId);
694
811
  this.skippedVideos.delete(producerId);
695
812
  const consumer = this.consumers.get(producerId);
696
- if (!consumer)
697
- break; // never consumed it, or already cleaned up
698
- this.videoSlots.delete(producerId);
699
- consumer.close();
813
+ consumer?.close();
700
814
  this.consumers.delete(producerId);
701
815
  this.remoteSources.delete(producerId);
702
816
  this.remoteInfo.delete(producerId);
703
- this.emit('trackEnded', { producerId, identity });
817
+ if (consumer)
818
+ this.emit('trackEnded', { producerId, identity });
704
819
  // A video slot opened under the fan-out limit: the oldest video left
705
820
  // waiting takes it.
706
- if (consumer.kind === 'video')
707
- void this.consumeSkipped().catch((err) => this.emit('error', err));
821
+ this.releaseVideoSlot(producerId);
708
822
  break;
709
823
  }
710
824
  case 'producerPaused': {
@@ -802,15 +916,19 @@ class StreamClient {
802
916
  // fresh one periodically so a reconnect at any point still works --
803
917
  // without this, any disconnect after the token's lifetime is terminal.
804
918
  const e = data;
805
- if (e.token)
919
+ if (e.token) {
920
+ this.serverTokenRevision += 1;
806
921
  this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
922
+ }
807
923
  break;
808
924
  }
809
925
  case 'mustReconnect': {
810
926
  const e = data;
811
927
  // A promotion hands us a fresh token carrying the new role.
812
- if (e.token)
928
+ if (e.token) {
929
+ this.serverTokenRevision += 1;
813
930
  this.setToken(e.token, null);
931
+ }
814
932
  this.emit('mustReconnect', e);
815
933
  if (e.retry) {
816
934
  this.teardownMedia();
@@ -836,6 +954,11 @@ class StreamClient {
836
954
  this.closedByUs = true;
837
955
  this.emit('roomClosed', d);
838
956
  this.finish('room-closed');
957
+ // Before close() fails what is pending: an endRoom() this event
958
+ // answers succeeded.
959
+ for (const ended of this.endRoomWaiters)
960
+ ended();
961
+ this.endRoomWaiters.clear();
839
962
  this.close();
840
963
  break;
841
964
  case 'kicked':
@@ -1569,8 +1692,11 @@ class StreamClient {
1569
1692
  await this.stopScreen('stopped');
1570
1693
  this.emit('error', err);
1571
1694
  });
1572
- if (this.music)
1573
- await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1695
+ if (this.music) {
1696
+ const music = this.music;
1697
+ const operation = this.musicOperation;
1698
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
1699
+ }
1574
1700
  if (generation !== this.publishGeneration)
1575
1701
  return;
1576
1702
  let stream = this.localStream;
@@ -1804,28 +1930,43 @@ class StreamClient {
1804
1930
  if (!this.mediaPermissions.audio)
1805
1931
  throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
1806
1932
  const generation = this.publishGeneration;
1807
- if (this.music)
1808
- await this.stopMusic();
1809
- const music = new music_js_1.MusicSource(input, options);
1810
- try {
1811
- await this.produceMusic(music, generation);
1812
- // Checked again here: an unpublish() after the producer was kept has
1813
- // already closed it, and the music must not be remembered for a
1814
- // reconnect to publish again.
1815
- if (generation !== this.publishGeneration)
1933
+ // Taken now, in call order: a music publish still under way is over, and
1934
+ // a stopMusic() from here on ends this one.
1935
+ const operation = ++this.musicOperation;
1936
+ await this.musicTask(async () => {
1937
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1816
1938
  throw unpublishedWhile('music');
1817
- }
1818
- catch (err) {
1819
- music.close();
1820
- throw err;
1821
- }
1822
- this.music = music;
1823
- this.wasPublishing = true;
1824
- this.emitLocalMedia();
1939
+ if (this.music)
1940
+ await this.closeMusic();
1941
+ const music = new music_js_1.MusicSource(input, options);
1942
+ try {
1943
+ await this.produceMusic(music, generation, operation);
1944
+ // Checked again here: an unpublish() or stopMusic() after the
1945
+ // producer was kept has already closed it, and the music must not be
1946
+ // remembered for a reconnect to publish again.
1947
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1948
+ throw unpublishedWhile('music');
1949
+ }
1950
+ catch (err) {
1951
+ music.close();
1952
+ throw err;
1953
+ }
1954
+ this.music = music;
1955
+ this.wasPublishing = true;
1956
+ this.emitLocalMedia();
1957
+ });
1825
1958
  }
1826
- async produceMusic(music, generation) {
1959
+ musicTask(task) {
1960
+ const run = this.musicQueue.then(task, task);
1961
+ this.musicQueue = run.catch(() => undefined);
1962
+ return run;
1963
+ }
1964
+ async produceMusic(music, generation, operation) {
1965
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
1966
+ // below, and wins.
1967
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
1827
1968
  const transport = await this.ensureSendTransport();
1828
- if (generation !== this.publishGeneration)
1969
+ if (!current())
1829
1970
  throw unpublishedWhile('music');
1830
1971
  const producer = await transport.produce({
1831
1972
  track: music.track,
@@ -1835,7 +1976,14 @@ class StreamClient {
1835
1976
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
1836
1977
  appData: { source: 'music' },
1837
1978
  });
1838
- await this.keepProducer(producer, generation, 'music');
1979
+ if (!current()) {
1980
+ producer.close();
1981
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1982
+ throw unpublishedWhile('music');
1983
+ }
1984
+ // Kept and named the music in one step, with no wait between: stopMusic()
1985
+ // finds the music producer by that name.
1986
+ this.producers.set(producer.id, producer);
1839
1987
  this.musicProducerId = producer.id;
1840
1988
  // Music switched off before a reconnect comes back paused here.
1841
1989
  await this.pauseOnServer(producer);
@@ -1864,8 +2012,17 @@ class StreamClient {
1864
2012
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1865
2013
  this.emitLocalMedia();
1866
2014
  }
1867
- /** Stop publishing music. The element or stream it came from is left as it was. */
2015
+ /**
2016
+ * Stop publishing music. The element or stream it came from is left as it
2017
+ * was. A publishMusic() still under way is stopped too: it rejects with
2018
+ * `unpublished` and publishes nothing.
2019
+ */
1868
2020
  async stopMusic() {
2021
+ this.musicOperation += 1;
2022
+ await this.closeMusic();
2023
+ }
2024
+ /** Close the music being published, here and at the server. */
2025
+ async closeMusic() {
1869
2026
  const id = this.musicProducerId;
1870
2027
  this.musicProducerId = null;
1871
2028
  this.music?.close();
@@ -1885,9 +2042,11 @@ class StreamClient {
1885
2042
  });
1886
2043
  }
1887
2044
  async consume(remote) {
2045
+ if (this.closedByUs)
2046
+ return;
1888
2047
  if (!this.device)
1889
2048
  throw new errors_js_1.RpcError('not-connected', 'not connected');
1890
- if (this.consumers.has(remote.producerId))
2049
+ if (this.subscriptions.has(remote.producerId))
1891
2050
  return;
1892
2051
  // Your own camera is already local; pulling it back off the SFU wastes a
1893
2052
  // consumer and shows you a delayed copy of yourself.
@@ -1907,35 +2066,69 @@ class StreamClient {
1907
2066
  }
1908
2067
  this.videoSlots.add(remote.producerId);
1909
2068
  }
2069
+ const operation = {};
2070
+ this.subscriptions.set(remote.producerId, operation);
1910
2071
  try {
1911
- await this.consumeNow(remote);
2072
+ await this.consumeNow(remote, operation);
1912
2073
  }
1913
2074
  catch (err) {
1914
- if (!this.consumers.has(remote.producerId))
1915
- this.videoSlots.delete(remote.producerId);
2075
+ // A closed producer or replaced session has already released its slot.
2076
+ // Its late failure must neither release a newer slot nor report a ghost error.
2077
+ if (this.subscriptions.get(remote.producerId) !== operation)
2078
+ return;
2079
+ this.subscriptions.delete(remote.producerId);
2080
+ this.consumers.get(remote.producerId)?.close();
2081
+ this.consumers.delete(remote.producerId);
2082
+ this.remoteVideoLayers.delete(remote.producerId);
2083
+ this.remoteSources.delete(remote.producerId);
2084
+ this.remoteInfo.delete(remote.producerId);
2085
+ this.releaseVideoSlot(remote.producerId);
1916
2086
  throw err;
1917
2087
  }
1918
2088
  }
2089
+ /**
2090
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2091
+ * freed it: an ended video, or a subscription that failed. Not while
2092
+ * reconnecting: a failure caused by the socket dropping would otherwise
2093
+ * start every waiting video on a dead connection, each failing with
2094
+ * not-connected; the reconnect's join lists them all again anyway.
2095
+ */
2096
+ releaseVideoSlot(producerId) {
2097
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
2098
+ this.consumeSkipped();
2099
+ }
1919
2100
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1920
- async consumeSkipped() {
2101
+ consumeSkipped() {
1921
2102
  for (const [producerId, remote] of this.skippedVideos) {
1922
2103
  const limit = this.mediaPermissions.maxVideoSubscriptions;
1923
2104
  if (limit !== null && this.videoSlots.size >= limit)
1924
2105
  return;
1925
2106
  this.skippedVideos.delete(producerId);
1926
- await this.consume(remote);
2107
+ // consume reserves synchronously, before its first await. Consecutive
2108
+ // releases cannot admit the same producer or exceed the limit.
2109
+ void this.consume(remote).catch((err) => this.emit('error', err));
1927
2110
  }
1928
2111
  }
1929
- async consumeNow(remote) {
2112
+ async consumeNow(remote, operation) {
1930
2113
  if (!this.device)
1931
2114
  throw new errors_js_1.RpcError('not-connected', 'not connected');
2115
+ const device = this.device;
2116
+ const current = (made) => {
2117
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
2118
+ return;
2119
+ made?.close();
2120
+ throw new errors_js_1.RpcError('not-connected', 'subscription ended during setup');
2121
+ };
1932
2122
  const transport = await this.ensureRecvTransport();
2123
+ current();
1933
2124
  const params = (await this.rpc('consume', {
1934
2125
  transportId: transport.id,
1935
2126
  producerId: remote.producerId,
1936
- rtpCapabilities: this.device.rtpCapabilities,
2127
+ rtpCapabilities: device.rtpCapabilities,
1937
2128
  }));
2129
+ current();
1938
2130
  const consumer = await transport.consume(params);
2131
+ current(consumer);
1939
2132
  this.consumers.set(remote.producerId, consumer);
1940
2133
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1941
2134
  // Layers asked for before a reconnect: the new consumer gets them before
@@ -1955,9 +2148,11 @@ class StreamClient {
1955
2148
  if (layers) {
1956
2149
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1957
2150
  }
2151
+ current(consumer);
1958
2152
  // The server created it paused. Resume only now that our end exists,
1959
2153
  // otherwise the first frames arrive before there is anything to decode them.
1960
2154
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2155
+ current(consumer);
1961
2156
  const kind = consumer.kind;
1962
2157
  const source = remote.source ?? defaultSource(kind);
1963
2158
  this.remoteSources.set(remote.producerId, source);
@@ -2104,7 +2299,20 @@ class StreamClient {
2104
2299
  }
2105
2300
  promote(identity, role) { return this.rpc('promote', { identity, role }); }
2106
2301
  kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
2107
- endRoom() { return this.rpc('endRoom'); }
2302
+ /**
2303
+ * End the room for everyone. The server tells the room it has closed
2304
+ * before it replies, and that roomClosed closes this session, so the reply
2305
+ * may never be read: roomClosed is then the answer, and endRoom() resolves
2306
+ * as the reply would have, with `{ ok: true }`. It still fails when the
2307
+ * server refuses, or the connection drops with neither.
2308
+ */
2309
+ endRoom() {
2310
+ return new Promise((resolve, reject) => {
2311
+ const ended = () => resolve({ ok: true });
2312
+ this.endRoomWaiters.add(ended);
2313
+ this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
2314
+ });
2315
+ }
2108
2316
  getRoomState() { return this.rpc('getRoomState'); }
2109
2317
  // ------------------------------------------------------- slots and metadata
2110
2318
  //