@q9labsai/chalk-client 5.2.6 → 5.3.0

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/README.md CHANGED
@@ -77,7 +77,7 @@ unsubscribe();
77
77
  `SpaceSnapshot` contains these slices:
78
78
 
79
79
  - `connection`: status, live Episode summary, and the latest failure
80
- - `self`: local Participant identity, role, capabilities, hand state, and `can(capability)`
80
+ - `self`: local Participant and verified person Identity, role, capabilities, hand state, and `can(capability)`
81
81
  - `participants`: roster and admission queue
82
82
  - `media`: device selection, local and remote media, screen share, and requests
83
83
  - `chat`: messages, pending sends, read receipts, unread count, and pagination
@@ -1,3 +1,145 @@
1
+ // src/media/receive-stall.ts
2
+ var RECEIVE_STALL_WINDOW_MS = 7e3;
3
+ var RECEIVE_STARTUP_GRACE_MS = 2e4;
4
+ var MAX_SAMPLE_GAP_MS = 3e3;
5
+ var ReceiveStallDetector = class {
6
+ #progress = /* @__PURE__ */ new Map();
7
+ #sampledAt;
8
+ forget(publicationId) {
9
+ this.#progress.delete(publicationId);
10
+ }
11
+ clear() {
12
+ this.#progress.clear();
13
+ this.#sampledAt = void 0;
14
+ }
15
+ sample(now, tracks) {
16
+ if (this.#sampledAt !== void 0 && (now - this.#sampledAt > MAX_SAMPLE_GAP_MS || now < this.#sampledAt)) this.clear();
17
+ this.#sampledAt = now;
18
+ const observed = new Set(tracks.map((track) => track.publicationId));
19
+ for (const id of this.#progress.keys()) if (!observed.has(id)) this.#progress.delete(id);
20
+ const observations = tracks.map((track) => ({ id: track.publicationId, outcome: this.#observeTrack(now, track) }));
21
+ return {
22
+ stalled: observations.filter((track) => track.outcome === "stalled").map((track) => track.id),
23
+ resumed: observations.filter((track) => track.outcome === "resumed").map((track) => track.id),
24
+ advanced: observations.filter((track) => track.outcome === "advanced" || track.outcome === "resumed").map((track) => track.id)
25
+ };
26
+ }
27
+ #observeTrack(now, { publicationId, trackId, packets }) {
28
+ const previous = this.#progress.get(publicationId);
29
+ const sameTrack = previous?.trackId === trackId;
30
+ if (sameTrack && previous.packets === packets) {
31
+ const window = previous.flowing ? RECEIVE_STALL_WINDOW_MS : RECEIVE_STARTUP_GRACE_MS;
32
+ return now - previous.advancedAt >= window ? "stalled" : null;
33
+ }
34
+ return this.#advanceTrack(now, { publicationId, trackId, packets }, previous);
35
+ }
36
+ #advanceTrack(now, { publicationId, trackId, packets }, previous) {
37
+ const advanced = previous?.trackId === trackId && packets > previous.packets;
38
+ const flowingSince = advanced && now - previous.advancedAt < RECEIVE_STALL_WINDOW_MS ? previous.flowingSince : now;
39
+ this.#progress.set(publicationId, { trackId, packets, advancedAt: now, flowing: advanced, flowingSince });
40
+ if (!advanced) return null;
41
+ return now - flowingSince >= RECEIVE_STALL_WINDOW_MS ? "resumed" : "advanced";
42
+ }
43
+ };
44
+ var MAX_RECEIVE_REBUILDS = 2;
45
+ var MAX_RECEIVE_OBSERVATIONS = 32;
46
+ var ReceiveStallRecovery = class {
47
+ #detector = new ReceiveStallDetector();
48
+ #attempts = /* @__PURE__ */ new Map();
49
+ #identities = /* @__PURE__ */ new Map();
50
+ #expected = /* @__PURE__ */ new Set();
51
+ #screens = /* @__PURE__ */ new Set();
52
+ #stalled = /* @__PURE__ */ new Set();
53
+ #active = [];
54
+ #observations = Object.freeze([]);
55
+ get observations() {
56
+ return this.#observations;
57
+ }
58
+ expect(publications, participantId) {
59
+ const enabled = publications.filter((publication) => publication.participantId !== participantId && publication.enabled);
60
+ this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
61
+ this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
62
+ this.#pruneWithdrawn(publications);
63
+ for (const publication of this.#identities.values()) {
64
+ if (this.#isExpected(publication)) continue;
65
+ this.#detector.forget(publication.publicationId);
66
+ this.#stalled.delete(publication.publicationId);
67
+ }
68
+ this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
69
+ }
70
+ suspend() {
71
+ this.#expected.clear();
72
+ this.#screens.clear();
73
+ this.clear();
74
+ }
75
+ #pruneWithdrawn(publications) {
76
+ const advertised = new Set(publications.map((publication) => publication.publicationId));
77
+ const screens = new Set(publications.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
78
+ for (const [key, publication] of this.#identities) {
79
+ if (advertised.has(publication.publicationId) || publication.source === "screen_audio" && screens.has(publication.participantId)) continue;
80
+ this.#forget(publication.publicationId);
81
+ this.#identities.delete(key);
82
+ }
83
+ }
84
+ isStalled(publicationId) {
85
+ return this.#stalled.has(publicationId);
86
+ }
87
+ canRebuild() {
88
+ return this.#active.length > 0 && this.#active.every((id) => this.#stalled.has(id) && (this.#attempts.get(id) ?? 0) < MAX_RECEIVE_REBUILDS);
89
+ }
90
+ reserveRebuild() {
91
+ if (!this.canRebuild()) return false;
92
+ for (const id of this.#active) this.#attempts.set(id, (this.#attempts.get(id) ?? 0) + 1);
93
+ return true;
94
+ }
95
+ clear() {
96
+ this.#detector.clear();
97
+ this.#stalled.clear();
98
+ this.#active = [];
99
+ }
100
+ sample(now, connection, report, tracks) {
101
+ const active = [...tracks].filter((publication) => this.#isExpected(publication));
102
+ for (const publication of active) this.#rememberIdentity(publication);
103
+ this.#active = active.map((publication) => publication.publicationId);
104
+ const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
105
+ const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
106
+ for (const id of resumed) this.#attempts.delete(id);
107
+ const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
108
+ this.#stalled = new Set(stalled);
109
+ if (newlyStalled.length) this.#record(now);
110
+ return { stalled, advanced, newlyStalled };
111
+ }
112
+ #isExpected(publication) {
113
+ return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
114
+ }
115
+ #rememberIdentity(publication) {
116
+ const key = `${publication.participantId}\0${publication.source}`;
117
+ const previous = this.#identities.get(key);
118
+ if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
119
+ this.#identities.set(key, publication);
120
+ }
121
+ #forget(id) {
122
+ this.#detector.forget(id);
123
+ this.#stalled.delete(id);
124
+ this.#attempts.delete(id);
125
+ }
126
+ #record(now) {
127
+ const observation = Object.freeze({ event: "receive_stalled", scope: this.#active.every((id) => this.#stalled.has(id)) ? "all" : "partial", observedAtMs: now, expectedTracks: this.#active.length, stalledTracks: this.#stalled.size });
128
+ this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
129
+ }
130
+ };
131
+ function receivePacketSample(connection, report, publication) {
132
+ const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
133
+ if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
134
+ if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
135
+ return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum, row) => sum + row.packetsReceived, 0) };
136
+ }
137
+ function audioPacketCadenceKnown(connection, report, track, rows) {
138
+ const negotiated = rows.length ? rows.map((row) => row.codecId ? report.get(row.codecId) : void 0) : connection.getReceivers().find((receiver) => receiver.track.id === track.id)?.getParameters().codecs ?? [];
139
+ const opus = negotiated.filter((codec) => codec?.mimeType?.toLowerCase() === "audio/opus");
140
+ return opus.length > 0 && opus.every((codec) => typeof codec.sdpFmtpLine === "string");
141
+ }
142
+
1
143
  // src/media/camera-uplink.ts
2
144
  var CameraUplinkPolicy = class {
3
145
  #healthySamples = 0;
@@ -234,6 +376,7 @@ function classifyMediaFailure(cause, fallbackStep) {
234
376
  // src/media/client.ts
235
377
  var EMPTY_LOCAL = Object.freeze([]);
236
378
  var EMPTY_REMOTE = Object.freeze([]);
379
+ var EMPTY_RECEIVE_STALLS = Object.freeze([]);
237
380
  var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
238
381
  var CONNECTION_TIMEOUT_MS = 8e3;
239
382
  var NEGOTIATION_TIMEOUT_MS = 35e3;
@@ -265,6 +408,7 @@ var CloudflareSFUClient = class {
265
408
  #cursor = null;
266
409
  #remotePullIncomplete = false;
267
410
  #remotePullRetries = /* @__PURE__ */ new Map();
411
+ #receiveProgress = new ReceiveStallRecovery();
268
412
  #remoteParticipantIds = null;
269
413
  #remoteParticipantRevision = 0;
270
414
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -281,6 +425,7 @@ var CloudflareSFUClient = class {
281
425
  #retirement = Promise.resolve();
282
426
  #snapshot;
283
427
  #started = false;
428
+ #receivingInitial = false;
284
429
  #stopped = false;
285
430
  #transport;
286
431
  constructor(options) {
@@ -300,6 +445,7 @@ var CloudflareSFUClient = class {
300
445
  cursor: null,
301
446
  localTracks: EMPTY_LOCAL,
302
447
  remoteTracks: EMPTY_REMOTE,
448
+ receiveStalls: this.#receiveProgress.observations,
303
449
  failure: null
304
450
  });
305
451
  this.#observeConnection(this.#connection, this.#generation, this.#connectionEpoch);
@@ -320,7 +466,9 @@ var CloudflareSFUClient = class {
320
466
  this.#remotePullIncomplete = true;
321
467
  this.remotePublicationsChanged();
322
468
  }
323
- remotePublicationsChanged() {
469
+ remotePublicationsChanged(publications, projectionState = "available") {
470
+ if (projectionState === "unavailable") this.#receiveProgress.suspend();
471
+ else if (publications) this.#receiveProgress.expect(publications, this.#participantId);
324
472
  if (this.#polling) {
325
473
  this.#pollAfterCurrent = true;
326
474
  return;
@@ -385,20 +533,30 @@ var CloudflareSFUClient = class {
385
533
  this.#publishSnapshot();
386
534
  this.#emitLocal();
387
535
  }
388
- async start(localMedia) {
536
+ async start(localMedia, beforePublish) {
389
537
  if (this.#started) return;
390
538
  this.#requireActive();
391
- const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
392
- for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
393
539
  const generation = this.#generation;
394
540
  this.#setPhase("connecting", null);
541
+ if (beforePublish) {
542
+ this.#receivingInitial = true;
543
+ this.#schedulePoll(0);
544
+ try {
545
+ localMedia = await beforePublish();
546
+ } finally {
547
+ this.#receivingInitial = false;
548
+ }
549
+ if (generation !== this.#generation || this.#stopped) return;
550
+ }
551
+ const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
552
+ for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
395
553
  await this.#activatePreparedTracks(
396
554
  [...this.#localTracks.values()].filter((state) => state.source === "microphone" || state.source === "camera"),
397
555
  generation
398
556
  );
399
557
  }
400
558
  async refreshRemotePublications() {
401
- if (!this.#started || this.#stopped || this.#polling) return;
559
+ if (!this.#started && !this.#receivingInitial || this.#stopped || this.#polling) return;
402
560
  this.#polling = true;
403
561
  const generation = this.#generation;
404
562
  this.#pollFailedPull = false;
@@ -478,12 +636,34 @@ var CloudflareSFUClient = class {
478
636
  return enabled ? Promise.resolve() : this.#retireOwnedPublications();
479
637
  }
480
638
  async closeForcedLocalPublication(source, cause = "forced_pause") {
639
+ const connection = this.#connection;
640
+ const generationAtClose = this.#generation;
481
641
  const sources = localSourceGroup(source);
482
642
  const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
483
- await this.setLocalSourceIntent(source, false, cause);
484
- if (!ownsPublication || !this.#replaceMediaConnection) return;
643
+ const canRecover = ownsPublication && this.#replaceMediaConnection !== void 0;
644
+ let failedRetirement;
645
+ try {
646
+ await this.setLocalSourceIntent(source, false, cause);
647
+ } catch (error) {
648
+ if (!canRecover) throw error;
649
+ failedRetirement = this.#fenceFailedClose(error, connection, generationAtClose, cause);
650
+ if (!failedRetirement) return;
651
+ }
652
+ if (!canRecover) return;
653
+ await this.#restoreEnabledSourcesAfterClose(source);
654
+ if (this.#retirement !== failedRetirement) await this.#retirement;
655
+ }
656
+ #fenceFailedClose(error, connection, generation, cause) {
657
+ const failures = error instanceof AggregateError ? error.errors : [error];
658
+ if (!failures.every((failure) => this.#isRetryableConnectionFailure(failure))) throw error;
659
+ if (connection !== this.#connection || generation !== this.#generation) return;
660
+ this.#retiredLocalConnection = connection;
661
+ this.#retiredLocalCause = cause;
662
+ return this.#retirement;
663
+ }
664
+ #restoreEnabledSourcesAfterClose(source) {
485
665
  const generation = this.#generation;
486
- await this.#serializeSDP(async () => {
666
+ return this.#serializeSDP(async () => {
487
667
  const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
488
668
  const states = await this.#prepareConnectionForPublication([], generation);
489
669
  await this.#publishPreparedTracksSerialized(
@@ -495,7 +675,6 @@ var CloudflareSFUClient = class {
495
675
  this.#requireGeneration(generation);
496
676
  this.#setPhase("live", null);
497
677
  });
498
- await this.#retirement;
499
678
  }
500
679
  async #retireUnavailableLocalPublication(state) {
501
680
  const publicationId = state.providerPublicationId;
@@ -641,6 +820,7 @@ var CloudflareSFUClient = class {
641
820
  return;
642
821
  }
643
822
  this.#stopped = true;
823
+ this.#receiveProgress.expect([], this.#participantId);
644
824
  this.#subscribedPublicationIds.clear();
645
825
  this.#generation++;
646
826
  this.#connectionEpoch++;
@@ -901,9 +1081,8 @@ var CloudflareSFUClient = class {
901
1081
  if (cursor === null) return;
902
1082
  const participantRevision = this.#remoteParticipantRevision;
903
1083
  const desired = this.#desiredRemotePublications(authoritative, cursor);
904
- const toPull = this.#pendingRemotePulls(desired);
905
1084
  this.#pollFailedPull = true;
906
- const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
1085
+ const pulled = await this.#pullWithRecovery(desired, cursor, generation);
907
1086
  if (pulled === null) return;
908
1087
  this.#requireGeneration(generation);
909
1088
  const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
@@ -931,7 +1110,9 @@ var CloudflareSFUClient = class {
931
1110
  }
932
1111
  const now = Date.now();
933
1112
  return [...desired].filter(([key, publication]) => {
934
- if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
1113
+ if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
1114
+ if (!this.#receiveProgress.canRebuild() || !this.#replaceMediaConnection) return false;
1115
+ }
935
1116
  const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
936
1117
  return remaining <= 0;
937
1118
  }).map(([, publication]) => publication);
@@ -960,7 +1141,14 @@ var CloudflareSFUClient = class {
960
1141
  #canSubscribeToRemotePublication(publication) {
961
1142
  return this.#remoteParticipantIds === null || this.#remoteParticipantIds.has(publication.participantId) || this.#subscribedPublicationIds.has(publication.publicationId);
962
1143
  }
963
- async #pullWithRecovery(publications, cursor, generation) {
1144
+ async #pullWithRecovery(desired, cursor, generation) {
1145
+ const pending = this.#pendingRemotePulls(desired);
1146
+ const receiveStalled = pending.some((publication) => this.#receiveProgress.isStalled(publication.publicationId));
1147
+ const publications = receiveStalled ? [...desired.values()] : pending;
1148
+ if (receiveStalled) {
1149
+ if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
1150
+ return this.#retryRemotePullOnReplacement(publications, cursor, generation, "receive_stalled");
1151
+ }
964
1152
  try {
965
1153
  return await this.#pull(publications, generation);
966
1154
  } catch (error) {
@@ -979,14 +1167,19 @@ var CloudflareSFUClient = class {
979
1167
  #hasLiveRemoteTracks() {
980
1168
  return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
981
1169
  }
982
- async #retryRemotePullOnReplacement(publications, cursor, generation) {
1170
+ async #retryRemotePullOnReplacement(publications, cursor, generation, cause = "remote_signaling_failure") {
983
1171
  this.#remoteRecoveryGeneration = generation;
1172
+ const connection = this.#connection;
984
1173
  try {
985
- await this.#replaceMediaConnectionForRecovery(generation, "remote_signaling_failure");
1174
+ await this.#replaceMediaConnectionForRecovery(generation, cause);
986
1175
  await this.#republishPreparedTracksAfterRecovery(generation);
987
1176
  return await this.#pull(publications, generation);
988
1177
  } catch (error) {
989
1178
  if (generation !== this.#generation || this.#stopped) throw error;
1179
+ if (cause === "receive_stalled" && connection === this.#connection) {
1180
+ this.#replacementAttemptedGeneration = null;
1181
+ for (const publication of publications) this.#backoffRemotePull(publication.publicationId, true);
1182
+ }
990
1183
  this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
991
1184
  throw error;
992
1185
  }
@@ -1029,7 +1222,7 @@ var CloudflareSFUClient = class {
1029
1222
  const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
1030
1223
  for (const publication of publications) {
1031
1224
  const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
1032
- this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
1225
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures, receiveStalled: this.#remotePullRetries.get(publication.publicationId)?.receiveStalled });
1033
1226
  }
1034
1227
  }
1035
1228
  async #pullBatch(publications, generation) {
@@ -1056,12 +1249,17 @@ var CloudflareSFUClient = class {
1056
1249
  connection.addEventListener("track", onTrack);
1057
1250
  try {
1058
1251
  this.#requireGeneration(generation);
1059
- const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
1252
+ const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
1253
+ generation,
1254
+ connectionId
1255
+ });
1060
1256
  this.#requireGeneration(generation);
1061
1257
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
1062
1258
  const responseTracks = response.tracks ?? [];
1063
1259
  const matched = matchRemotePullTracks(publications, responseTracks);
1064
- for (const { publication } of matched) this.#remotePullRetries.delete(publication.publicationId);
1260
+ for (const { publication } of matched) {
1261
+ if (!this.#remotePullRetries.get(publication.publicationId)?.receiveStalled) this.#remotePullRetries.delete(publication.publicationId);
1262
+ }
1065
1263
  await this.#completeRenegotiation(response, connection, connectionId, generation);
1066
1264
  if (matched.length === 0) return [];
1067
1265
  await this.#waitForConnection(connection, generation);
@@ -1091,13 +1289,33 @@ var CloudflareSFUClient = class {
1091
1289
  for (const failure of failures) {
1092
1290
  const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
1093
1291
  if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
1094
- const previous = this.#remotePullRetries.get(publication.publicationId) ?? { retryAfter: 0, failures: 0 };
1095
- const failureCount = Math.min(previous.failures + 1, 6);
1096
- const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
1097
- this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
1292
+ this.#backoffRemotePull(publication.publicationId);
1098
1293
  this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
1099
1294
  }
1100
1295
  }
1296
+ #backoffRemotePull(publicationId, receiveStalled = false) {
1297
+ const previous = this.#remotePullRetries.get(publicationId) ?? { retryAfter: 0, failures: 0 };
1298
+ const failures = Math.min(previous.failures + 1, 6);
1299
+ this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
1300
+ }
1301
+ #canObserveReceiveProgress(connection) {
1302
+ return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
1303
+ }
1304
+ #observeReceiveProgress(connection, report) {
1305
+ if (!this.#canObserveReceiveProgress(connection)) {
1306
+ this.#receiveProgress.clear();
1307
+ return;
1308
+ }
1309
+ const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
1310
+ for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
1311
+ this.#publishSnapshot();
1312
+ if (!this.#receiveProgress.canRebuild()) return;
1313
+ for (const publicationId of stalled) {
1314
+ if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
1315
+ }
1316
+ this.#remotePullIncomplete = true;
1317
+ this.remotePublicationsChanged();
1318
+ }
1101
1319
  async #completeRenegotiation(response, connection, connectionId, generation) {
1102
1320
  if (!response.requiresImmediateRenegotiation) return;
1103
1321
  await this.#applyProviderDescription(response, connection);
@@ -1203,14 +1421,12 @@ var CloudflareSFUClient = class {
1203
1421
  cameraPolicy.reset();
1204
1422
  observedCamera = state;
1205
1423
  }
1206
- const sender = this.#cameraSimulcastSender(state);
1207
- if (!sender) {
1208
- cameraPolicy.reset();
1209
- return;
1210
- }
1211
1424
  const report = await connection.getStats();
1212
- if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
1213
- await this.#applyCameraUplink(sender, report, cameraPolicy);
1425
+ if (!current() || connection.signalingState !== "stable") return;
1426
+ this.#observeReceiveProgress(connection, report);
1427
+ const sender = this.#cameraSimulcastSender(state);
1428
+ if (!sender) cameraPolicy.reset();
1429
+ else await this.#applyCameraUplink(sender, report, cameraPolicy);
1214
1430
  } catch {
1215
1431
  cameraPolicy.reset();
1216
1432
  } finally {
@@ -1312,6 +1528,7 @@ var CloudflareSFUClient = class {
1312
1528
  }
1313
1529
  }
1314
1530
  #disposeConnection(stopSenders) {
1531
+ this.#receiveProgress.clear();
1315
1532
  if (!stopSenders) this.#setPhase("recovering", null);
1316
1533
  this.#disposeConnectionObservation?.();
1317
1534
  this.#disposeConnectionObservation = void 0;
@@ -1349,7 +1566,7 @@ var CloudflareSFUClient = class {
1349
1566
  this.#emitRemote();
1350
1567
  }
1351
1568
  #schedulePoll(delayMs = this.#pollIntervalMs) {
1352
- if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
1569
+ if (this.#stopped || !this.#started && !this.#receivingInitial || this.#pollTimer !== void 0) return;
1353
1570
  this.#pollTimer = globalThis.setTimeout(async () => {
1354
1571
  this.#pollTimer = void 0;
1355
1572
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
@@ -1408,6 +1625,7 @@ var CloudflareSFUClient = class {
1408
1625
  })
1409
1626
  ) : EMPTY_LOCAL,
1410
1627
  remoteTracks: this.#remoteTracks.size ? [...this.#remoteTracks.values()] : EMPTY_REMOTE,
1628
+ receiveStalls: this.#receiveProgress.observations,
1411
1629
  failure
1412
1630
  });
1413
1631
  if (snapshotEqual(this.#snapshot, next)) return;
@@ -1532,7 +1750,7 @@ function snapshotEqual(left, right) {
1532
1750
  return snapshotMetadataEqual(left, right) && left.localTracks.every((publication, index) => publicationEqual(publication, right.localTracks[index])) && left.remoteTracks.every((publication, index) => remotePublicationEqual(publication, right.remoteTracks[index]));
1533
1751
  }
1534
1752
  function snapshotMetadataEqual(left, right) {
1535
- return connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
1753
+ return (left.receiveStalls ?? EMPTY_RECEIVE_STALLS) === (right.receiveStalls ?? EMPTY_RECEIVE_STALLS) && connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
1536
1754
  }
1537
1755
  function connectionEqual(left, right) {
1538
1756
  return left.phase === right.phase && left.peerConnectionState === right.peerConnectionState && left.iceConnectionState === right.iceConnectionState;