@stream-io/video-client 1.54.0 → 1.55.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9641 -8767
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9638 -8764
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9639 -8765
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  12. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  13. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  14. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  16. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  17. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  18. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  19. package/dist/src/rtc/Publisher.d.ts +5 -2
  20. package/dist/src/rtc/Subscriber.d.ts +8 -0
  21. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  22. package/dist/src/rtc/types.d.ts +2 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  25. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  26. package/dist/src/stats/rtc/types.d.ts +10 -4
  27. package/package.json +5 -3
  28. package/src/Call.ts +83 -35
  29. package/src/StreamSfuClient.ts +36 -21
  30. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  31. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  32. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  33. package/src/coordinator/connection/connection.ts +8 -5
  34. package/src/gen/google/protobuf/struct.ts +7 -12
  35. package/src/gen/google/protobuf/timestamp.ts +6 -7
  36. package/src/gen/video/sfu/event/events.ts +22 -25
  37. package/src/gen/video/sfu/models/models.ts +10 -1
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
  39. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  40. package/src/helpers/__tests__/browsers.test.ts +12 -12
  41. package/src/helpers/browsers.ts +5 -5
  42. package/src/reporting/ClientEventReporter.ts +17 -12
  43. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  44. package/src/rtc/BasePeerConnection.ts +15 -34
  45. package/src/rtc/IceTrickleBuffer.ts +105 -12
  46. package/src/rtc/Publisher.ts +23 -19
  47. package/src/rtc/Subscriber.ts +97 -36
  48. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  49. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  50. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  51. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  52. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  53. package/src/rtc/helpers/degradationPreference.ts +1 -0
  54. package/src/rtc/helpers/iceCandiates.ts +35 -0
  55. package/src/rtc/helpers/sdp.ts +3 -2
  56. package/src/rtc/helpers/tracks.ts +2 -0
  57. package/src/rtc/types.ts +2 -0
  58. package/src/stats/SfuStatsReporter.ts +149 -49
  59. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  60. package/src/stats/rtc/StatsTracer.ts +90 -32
  61. package/src/stats/rtc/Tracer.ts +23 -2
  62. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  63. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  64. package/src/stats/rtc/types.ts +11 -4
@@ -2,9 +2,11 @@ import { combineLatest } from 'rxjs';
2
2
  import { StreamSfuClient } from '../StreamSfuClient';
3
3
  import { OwnCapability, StatsOptions } from '../gen/coordinator';
4
4
  import { Publisher, Subscriber } from '../rtc';
5
- import { Tracer, TraceRecord } from './rtc';
5
+ import { ComputedStats, PendingDelta, Tracer, TraceRecord } from './rtc';
6
6
  import { flatten, getSdkName, getSdkVersion } from './utils';
7
7
  import { getDeviceState, getWebRTCInfo } from '../helpers/client-details';
8
+ import { hasPending, withoutConcurrency } from '../helpers/concurrency';
9
+ import { timeboxed } from '../coordinator/connection/utils';
8
10
  import {
9
11
  ClientDetails,
10
12
  InputDevices,
@@ -51,6 +53,8 @@ export class SfuStatsReporter {
51
53
  private readonly sdkVersion: string;
52
54
  private readonly webRTCVersion: string;
53
55
  private readonly inputDevices = new Map<'mic' | 'camera', InputDevices>();
56
+ private readonly statsConcurrencyTag = Symbol('sfuStatsReporter');
57
+ private isStopped = false;
54
58
 
55
59
  constructor(
56
60
  sfuClient: StreamSfuClient,
@@ -158,69 +162,125 @@ export class SfuStatsReporter {
158
162
  });
159
163
  };
160
164
 
161
- private run = async (telemetry?: Telemetry) => {
162
- const [subscriberStats, publisherStats] = await Promise.all([
163
- this.subscriber.stats.get(),
164
- this.publisher?.stats.get(),
165
+ /**
166
+ * Samples both peer connections. Each `StatsTracer.takeSample()` is serialized
167
+ * internally, so this is safe even if it overlaps another sample (e.g. the
168
+ * connection-state-change handler). Kept separate from `send()` so an
169
+ * explicit flush can capture the sample from live peer connections before
170
+ * they are disposed, without waiting for an in-flight send.
171
+ */
172
+ private sample = (): Promise<[ComputedStats, ComputedStats | undefined]> =>
173
+ Promise.all([
174
+ this.subscriber.stats.takeSample(),
175
+ this.publisher?.stats.takeSample(),
165
176
  ]);
166
177
 
167
- this.subscriber.tracer?.trace('getstats', subscriberStats.delta);
168
- if (publisherStats) {
169
- this.publisher?.tracer?.trace('getstats', publisherStats.delta);
170
- }
178
+ private send = (
179
+ subscriberStats: ComputedStats,
180
+ publisherStats: ComputedStats | undefined,
181
+ telemetry?: Telemetry,
182
+ ) => {
183
+ // serialize sends so overlapping ones can't race on the trace buffers or
184
+ // deliver an older delta after a newer one already succeeded. Not gated by
185
+ // `isStopped`: an explicit final flush must still deliver after stop().
186
+ return withoutConcurrency(this.statsConcurrencyTag, async () => {
187
+ // The delta chain is delivered only when delta tracing is enabled
188
+ // (the peer-connection tracer exists). Otherwise we drop the chain so it
189
+ // can't grow unbounded.
190
+ const subTracer = this.subscriber.tracer;
191
+ const pubTracer = this.publisher?.tracer;
192
+ let subPending: PendingDelta[] = [];
193
+ if (subTracer) {
194
+ subPending = this.subscriber.stats.getPendingDeltas();
195
+ } else {
196
+ this.subscriber.stats.clearPendingDeltas();
197
+ }
198
+ let pubPending: PendingDelta[] = [];
199
+ if (pubTracer && publisherStats) {
200
+ pubPending = this.publisher?.stats.getPendingDeltas() ?? [];
201
+ } else {
202
+ this.publisher?.stats.clearPendingDeltas();
203
+ }
171
204
 
172
- const subscriberTrace = this.subscriber.tracer?.take();
173
- const publisherTrace = this.publisher?.tracer?.take();
174
- const tracer = this.tracer.take();
175
- const sfuTrace = this.sfuClient.getTrace();
176
- const traces: TraceRecord[] = [
177
- ...tracer.snapshot,
178
- ...(sfuTrace?.snapshot ?? []),
179
- ...(publisherTrace?.snapshot ?? []),
180
- ...(subscriberTrace?.snapshot ?? []),
181
- ];
205
+ const subscriberTrace = subTracer?.take();
206
+ const publisherTrace = pubTracer?.take();
207
+ const tracer = this.tracer.take();
208
+ const sfuTrace = this.sfuClient.getTrace();
209
+ const traces: TraceRecord[] = [
210
+ ...tracer.snapshot,
211
+ ...(sfuTrace?.snapshot ?? []),
212
+ ...(publisherTrace?.snapshot ?? []),
213
+ ...(subscriberTrace?.snapshot ?? []),
214
+ ...toGetStatsRecords(subPending, subTracer?.id),
215
+ ...toGetStatsRecords(pubPending, pubTracer?.id),
216
+ ];
182
217
 
183
- try {
184
- await this.sfuClient.sendStats({
185
- sdk: this.sdkName,
186
- sdkVersion: this.sdkVersion,
187
- webrtcVersion: this.webRTCVersion,
188
- subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
189
- publisherStats: publisherStats
190
- ? JSON.stringify(flatten(publisherStats.stats))
191
- : '[]',
192
- subscriberRtcStats: '',
193
- publisherRtcStats: '',
194
- rtcStats: JSON.stringify(traces),
195
- encodeStats: publisherStats?.performanceStats ?? [],
196
- decodeStats: subscriberStats.performanceStats,
197
- audioDevices: this.inputDevices.get('mic'),
198
- videoDevices: this.inputDevices.get('camera'),
199
- unifiedSessionId: this.unifiedSessionId,
200
- deviceState: getDeviceState(),
201
- telemetry,
202
- });
203
- } catch (err) {
204
- publisherTrace?.rollback();
205
- subscriberTrace?.rollback();
206
- tracer.rollback();
207
- sfuTrace?.rollback();
208
- throw err;
209
- }
218
+ try {
219
+ const { response } = await this.sfuClient.sendStats({
220
+ sdk: this.sdkName,
221
+ sdkVersion: this.sdkVersion,
222
+ webrtcVersion: this.webRTCVersion,
223
+ subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
224
+ publisherStats: publisherStats
225
+ ? JSON.stringify(flatten(publisherStats.stats))
226
+ : '[]',
227
+ subscriberRtcStats: '',
228
+ publisherRtcStats: '',
229
+ rtcStats: JSON.stringify(traces),
230
+ encodeStats: publisherStats?.performanceStats ?? [],
231
+ decodeStats: subscriberStats.performanceStats,
232
+ audioDevices: this.inputDevices.get('mic'),
233
+ videoDevices: this.inputDevices.get('camera'),
234
+ unifiedSessionId: this.unifiedSessionId,
235
+ deviceState: getDeviceState(),
236
+ telemetry,
237
+ });
238
+ // An SFU application-layer error means the stats were not accepted.
239
+ // Treat it like a transport failure: retain the chain and roll back the
240
+ // RTC traces (handled by the catch below) instead of committing.
241
+ if (response?.error) {
242
+ throw new Error(`SFU rejected stats: ${response.error.message}`);
243
+ }
244
+ // delivery confirmed: advance the delivery baseline for each chain.
245
+ if (subTracer) this.subscriber.stats.commitDeltas(subPending);
246
+ if (pubTracer && publisherStats) {
247
+ this.publisher?.stats.commitDeltas(pubPending);
248
+ }
249
+ } catch (err) {
250
+ // keep the delta chains (re-sent next interval); only the append-only
251
+ // RTC event traces are rolled back so they aren't lost.
252
+ publisherTrace?.rollback();
253
+ subscriberTrace?.rollback();
254
+ tracer.rollback();
255
+ sfuTrace?.rollback();
256
+ throw err;
257
+ }
258
+ });
259
+ };
260
+
261
+ /**
262
+ * Samples and sends one report. Used by the scheduler and the telemetry path.
263
+ * Bails if the reporter has been stopped so it never samples disposed peer
264
+ * connections.
265
+ */
266
+ private run = async (telemetry?: Telemetry) => {
267
+ if (this.isStopped) return;
268
+ const [subscriberStats, publisherStats] = await this.sample();
269
+ await this.send(subscriberStats, publisherStats, telemetry);
210
270
  };
211
271
 
212
272
  private scheduleNextReport = () => {
213
273
  const intervals = [1500, 3000, 3000, 5000];
214
274
  if (this.reportCount < intervals.length) {
215
275
  this.timeoutId = setTimeout(() => {
216
- this.flush();
276
+ this.scheduledFlush();
217
277
  this.reportCount++;
218
278
  this.scheduleNextReport();
219
279
  }, intervals[this.reportCount]);
220
280
  } else {
221
281
  clearInterval(this.intervalId);
222
282
  this.intervalId = setInterval(() => {
223
- this.flush();
283
+ this.scheduledFlush();
224
284
  }, this.options.reporting_interval_ms);
225
285
  }
226
286
  };
@@ -231,6 +291,7 @@ export class SfuStatsReporter {
231
291
  this.observeDevice(this.microphone, 'mic');
232
292
  this.observeDevice(this.camera, 'camera');
233
293
 
294
+ this.isStopped = false;
234
295
  this.reportCount = 0;
235
296
  clearInterval(this.intervalId);
236
297
  clearTimeout(this.timeoutId);
@@ -239,6 +300,7 @@ export class SfuStatsReporter {
239
300
  };
240
301
 
241
302
  stop = () => {
303
+ this.isStopped = true;
242
304
  this.unsubscribeDevicePermissionsSubscription?.();
243
305
  this.unsubscribeDevicePermissionsSubscription = undefined;
244
306
  this.unsubscribeListDevicesSubscription?.();
@@ -252,9 +314,47 @@ export class SfuStatsReporter {
252
314
  this.reportCount = 0;
253
315
  };
254
316
 
255
- flush = () => {
317
+ /**
318
+ * Explicit/final flush (leave, migration, re-init). Time-boxes the sampling
319
+ * step and swallows its failures, so a slow or failing `getStats()` on a
320
+ * degraded or closing peer connection can never block or reject call teardown
321
+ * or reconnect setup. On a successful sample it fires the send best-effort;
322
+ * the returned promise resolves once the sample is taken (or the time-box
323
+ * elapses / sampling fails), never when the sending completes. No-op once the
324
+ * reporter has been stopped.
325
+ */
326
+ flush = async (): Promise<void> => {
327
+ if (this.isStopped) return;
328
+ try {
329
+ const [sample] = await timeboxed([this.sample()], 2000);
330
+ const [subscriberStats, publisherStats] = sample;
331
+ this.send(subscriberStats, publisherStats).catch((err) => {
332
+ this.logger.warn('Failed to flush report stats', err);
333
+ });
334
+ } catch (err) {
335
+ this.logger.warn('Failed to sample stats for the final flush', err);
336
+ }
337
+ };
338
+
339
+ /**
340
+ * Flush entry for the periodic scheduler. Skips when the reporter is stopped
341
+ * or a send is already in flight so ticks can't pile up under slow sends (the
342
+ * next sample's delta spans the skipped interval).
343
+ */
344
+ private scheduledFlush = () => {
345
+ if (this.isStopped || hasPending(this.statsConcurrencyTag)) return;
256
346
  this.run().catch((err) => {
257
347
  this.logger.warn('Failed to flush report stats', err);
258
348
  });
259
349
  };
260
350
  }
351
+
352
+ /**
353
+ * Wraps un-acked, delta-compressed samples into the legacy `getstats` trace
354
+ * record shape so the chain rides inside `rtcStats`, wire-compatible with the
355
+ * server's existing decoder.
356
+ */
357
+ const toGetStatsRecords = (
358
+ pending: PendingDelta[],
359
+ id: string | null = null,
360
+ ): TraceRecord[] => pending.map(({ delta, ts }) => ['getstats', id, delta, ts]);
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { BehaviorSubject } from 'rxjs';
3
+ import { SfuStatsReporter } from '../SfuStatsReporter';
4
+ import { promiseWithResolvers } from '../../helpers/promise';
5
+
6
+ // A FinishedUnaryCall-shaped success (no application-layer error).
7
+ const okResponse = { response: {} };
8
+
9
+ const makeSlice = () => ({ snapshot: [] as unknown[], rollback: vi.fn() });
10
+
11
+ const makeStats = (pending: Array<{ delta: object; ts: number }>) => ({
12
+ takeSample: vi.fn().mockResolvedValue({
13
+ performanceStats: [],
14
+ stats: new Map(),
15
+ }),
16
+ getPendingDeltas: vi.fn(() => pending),
17
+ commitDeltas: vi.fn(),
18
+ clearPendingDeltas: vi.fn(),
19
+ });
20
+
21
+ const makeTracer = (id: string) => ({
22
+ take: vi.fn(makeSlice),
23
+ trace: vi.fn(),
24
+ id,
25
+ });
26
+
27
+ const build = (
28
+ opts: { withTracer?: boolean; withPublisher?: boolean } = {},
29
+ ) => {
30
+ const withTracer = opts.withTracer ?? true;
31
+ const subPending = [{ delta: { sub: 1 }, ts: 111 }];
32
+ const pubPending = [{ delta: { pub: 1 }, ts: 222 }];
33
+
34
+ const subStats = makeStats(subPending);
35
+ const subTracer = withTracer ? makeTracer('sub-id') : undefined;
36
+ const subscriber = { stats: subStats, tracer: subTracer };
37
+
38
+ let publisher: unknown;
39
+ let pubStats: ReturnType<typeof makeStats> | undefined;
40
+ if (opts.withPublisher) {
41
+ pubStats = makeStats(pubPending);
42
+ publisher = {
43
+ stats: pubStats,
44
+ tracer: withTracer ? makeTracer('pub-id') : undefined,
45
+ };
46
+ }
47
+
48
+ const sendStats = vi.fn().mockResolvedValue(okResponse);
49
+ const sfuClient = { sendStats, getTrace: vi.fn(() => undefined) };
50
+ const callTracer = { take: vi.fn(makeSlice), setEnabled: vi.fn() };
51
+
52
+ // device permission is 'denied' so observeDevice() doesn't reach listDevices()
53
+ const microphone = {
54
+ state: { browserPermissionState$: new BehaviorSubject('denied') },
55
+ };
56
+ const camera = {
57
+ state: { browserPermissionState$: new BehaviorSubject('denied') },
58
+ };
59
+ const state = { ownCapabilities$: new BehaviorSubject([]) };
60
+
61
+ const reporter = new SfuStatsReporter(sfuClient as never, {
62
+ options: { reporting_interval_ms: 1000, enable_rtc_stats: true } as never,
63
+ clientDetails: { sdk: undefined, browser: undefined } as never,
64
+ subscriber: subscriber as never,
65
+ publisher: publisher as never,
66
+ microphone: microphone as never,
67
+ camera: camera as never,
68
+ state: state as never,
69
+ tracer: callTracer as never,
70
+ unifiedSessionId: 'unified',
71
+ });
72
+
73
+ return {
74
+ reporter,
75
+ sendStats,
76
+ subStats,
77
+ subTracer,
78
+ pubStats,
79
+ callTracer,
80
+ subPending,
81
+ pubPending,
82
+ };
83
+ };
84
+
85
+ describe('SfuStatsReporter delta delivery', () => {
86
+ it('commits the un-acked chain after a successful send', async () => {
87
+ const t = build();
88
+ t.reporter.flush();
89
+ await vi.waitFor(() => expect(t.subStats.commitDeltas).toHaveBeenCalled());
90
+ expect(t.subStats.commitDeltas).toHaveBeenCalledWith(t.subPending);
91
+ });
92
+
93
+ it('does not re-trace the delta into the peer-connection tracer', async () => {
94
+ const t = build();
95
+ t.reporter.flush();
96
+ await vi.waitFor(() => expect(t.sendStats).toHaveBeenCalled());
97
+ expect(t.subTracer!.trace).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('ships the un-acked chain as getstats records inside rtcStats', async () => {
101
+ const t = build();
102
+ t.reporter.flush();
103
+ await vi.waitFor(() => expect(t.sendStats).toHaveBeenCalled());
104
+ const traces = JSON.parse(t.sendStats.mock.calls[0][0].rtcStats);
105
+ expect(traces).toContainEqual(['getstats', 'sub-id', { sub: 1 }, 111]);
106
+ });
107
+
108
+ it('retains the chain and rolls back generic traces on send failure', async () => {
109
+ const t = build();
110
+ const slice = makeSlice();
111
+ t.callTracer.take.mockReturnValue(slice);
112
+ t.sendStats.mockRejectedValue(new Error('network down'));
113
+
114
+ t.reporter.flush();
115
+ await vi.waitFor(() => expect(slice.rollback).toHaveBeenCalled());
116
+ expect(t.subStats.commitDeltas).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('treats an SFU application error as a failed send (no commit, rolls back)', async () => {
120
+ const t = build();
121
+ const slice = makeSlice();
122
+ t.callTracer.take.mockReturnValue(slice);
123
+ t.sendStats.mockResolvedValue({
124
+ response: { error: { code: 1, message: 'rejected', shouldRetry: false } },
125
+ });
126
+
127
+ t.reporter.flush();
128
+ await vi.waitFor(() => expect(slice.rollback).toHaveBeenCalled());
129
+ expect(t.subStats.commitDeltas).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('flush() resolves once the sample is taken, without awaiting the send', async () => {
133
+ const t = build();
134
+ const dGet = promiseWithResolvers<object>();
135
+ t.subStats.takeSample.mockReturnValue(dGet.promise); // sampling hangs
136
+ t.sendStats.mockReturnValue(promiseWithResolvers<object>().promise); // send would hang too
137
+
138
+ let resolved = false;
139
+ const flushed = Promise.resolve(t.reporter.flush()).then(() => {
140
+ resolved = true;
141
+ });
142
+
143
+ await Promise.resolve();
144
+ expect(resolved).toBe(false); // still sampling -> flush not resolved
145
+
146
+ dGet.resolve({ delta: {}, performanceStats: [], stats: new Map() });
147
+ await flushed;
148
+ expect(resolved).toBe(true); // resolves after the sample, not the send
149
+ });
150
+
151
+ it('does not reject (and skips the send) when the final sample fails', async () => {
152
+ const t = build();
153
+ t.subStats.takeSample.mockRejectedValue(new Error('getStats failed'));
154
+
155
+ await expect(t.reporter.flush()).resolves.toBeUndefined();
156
+ expect(t.sendStats).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('does not block teardown when the final sample hangs past the time-box', async () => {
160
+ vi.useFakeTimers();
161
+ try {
162
+ const t = build();
163
+ // sampling never settles (e.g. getStats() wedged on a closing connection)
164
+ t.subStats.takeSample.mockReturnValue(
165
+ promiseWithResolvers<object>().promise,
166
+ );
167
+
168
+ let settled = false;
169
+ const flushed = t.reporter.flush().then(() => {
170
+ settled = true;
171
+ });
172
+
173
+ await vi.advanceTimersByTimeAsync(2000); // time-box elapses
174
+ await flushed;
175
+
176
+ expect(settled).toBe(true);
177
+ expect(t.sendStats).not.toHaveBeenCalled();
178
+ } finally {
179
+ vi.useRealTimers();
180
+ }
181
+ });
182
+
183
+ it('samples on an explicit flush even while a previous send is in flight', async () => {
184
+ const t = build();
185
+ const d = promiseWithResolvers<object>();
186
+ t.sendStats.mockReturnValueOnce(d.promise).mockResolvedValue(okResponse);
187
+
188
+ t.reporter.flush(); // flush #1: samples, fires the slow send
189
+ await vi.waitFor(() => expect(t.sendStats).toHaveBeenCalledTimes(1));
190
+
191
+ await t.reporter.flush(); // flush #2: must sample now, not wait for send #1
192
+ expect(t.subStats.takeSample).toHaveBeenCalledTimes(2);
193
+
194
+ d.resolve(okResponse);
195
+ });
196
+
197
+ it('does not sample after stop()', async () => {
198
+ const t = build();
199
+ t.reporter.stop();
200
+ await t.reporter.flush();
201
+ expect(t.subStats.takeSample).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('skips overlapping scheduled reports while a run is in flight', async () => {
205
+ vi.useFakeTimers();
206
+ try {
207
+ const t = build();
208
+ const d = promiseWithResolvers<object>();
209
+ t.sendStats.mockReturnValue(d.promise); // first scheduled send hangs
210
+
211
+ t.reporter.start();
212
+ await vi.advanceTimersByTimeAsync(1500); // first scheduled tick -> run in flight
213
+ expect(t.subStats.takeSample).toHaveBeenCalledTimes(1);
214
+
215
+ await vi.advanceTimersByTimeAsync(3000); // next tick must be skipped
216
+ expect(t.subStats.takeSample).toHaveBeenCalledTimes(1);
217
+
218
+ d.resolve(okResponse);
219
+ t.reporter.stop();
220
+ } finally {
221
+ vi.useRealTimers();
222
+ }
223
+ });
224
+
225
+ it('clears the chain instead of sending it when delta tracing is disabled', async () => {
226
+ const t = build({ withTracer: false });
227
+ t.reporter.flush();
228
+ await vi.waitFor(() => expect(t.sendStats).toHaveBeenCalled());
229
+
230
+ expect(t.subStats.clearPendingDeltas).toHaveBeenCalled();
231
+ expect(t.subStats.commitDeltas).not.toHaveBeenCalled();
232
+ const traces = JSON.parse(t.sendStats.mock.calls[0][0].rtcStats);
233
+ expect(traces.some((r: unknown[]) => r[0] === 'getstats')).toBe(false);
234
+ });
235
+ });
@@ -5,7 +5,8 @@ import {
5
5
  TrackType,
6
6
  } from '../../gen/video/sfu/models/models';
7
7
  import type { RTCCodecStats, RTCMediaSourceStats } from '../types';
8
- import type { ComputedStats } from './types';
8
+ import type { ComputedStats, PendingDelta } from './types';
9
+ import { withoutConcurrency } from '../../helpers/concurrency';
9
10
 
10
11
  /**
11
12
  * StatsTracer is a class that collects and processes WebRTC stats.
@@ -20,13 +21,27 @@ export class StatsTracer {
20
21
  private readonly peerType: PeerType;
21
22
  private readonly trackIdToTrackType: Map<string, TrackType>;
22
23
  private readonly driftThresholdMs: number;
24
+ private readonly maxPendingDeltas: number;
25
+ // serializes takeSample() so overlapping callers (the reporter and the
26
+ // connection-state-change handler) can't interleave their getStats() and
27
+ // corrupt the previousSample/pendingDeltas read-modify-write.
28
+ private readonly sampleTag = Symbol('statsTracerSample');
23
29
 
24
30
  private costOverrides?: Map<TrackType, number>;
25
31
 
26
- private previousStats: Record<string, RTCStats> = {};
32
+ private previousSample: Record<string, RTCStats> = {};
27
33
  private frameTimeHistory: number[] = [];
28
34
  private fpsHistory: number[] = [];
29
35
 
36
+ /**
37
+ * The un-acked, delta-compressed samples awaiting delivery confirmation.
38
+ * Each entry's delta is computed against the immediately preceding sample,
39
+ * so the list forms a chain the server applies in order. The delivery
40
+ * baseline advances only when the reporter calls `commitDeltas` after a
41
+ * successful send; until then the chain is re-sent in full.
42
+ */
43
+ private pendingDeltas: PendingDelta[] = [];
44
+
30
45
  /**
31
46
  * Creates a new StatsTracer instance.
32
47
  */
@@ -35,43 +50,86 @@ export class StatsTracer {
35
50
  peerType: PeerType,
36
51
  trackIdToTrackType: Map<string, TrackType>,
37
52
  statsTimestampDriftThresholdMs: number = 0,
53
+ maxPendingDeltas: number = 50,
38
54
  ) {
39
55
  this.pc = pc;
40
56
  this.peerType = peerType;
41
57
  this.trackIdToTrackType = trackIdToTrackType;
42
58
  this.driftThresholdMs = statsTimestampDriftThresholdMs;
59
+ this.maxPendingDeltas = maxPendingDeltas;
43
60
  }
44
61
 
45
62
  /**
46
- * Get the stats from the RTCPeerConnection.
47
- * When called, it will return the stats for the current connection.
48
- * It will also return the delta between the current stats and the previous stats.
49
- * This is used to track the performance of the connection.
63
+ * Samples the RTCPeerConnection: returns the current stats report and the
64
+ * derived performance stats, and appends the delta-compressed sample to the
65
+ * un-acked delivery chain (retrieved via `getPendingDeltas`).
66
+ *
67
+ * @internal
68
+ */
69
+ takeSample = (): Promise<ComputedStats> => {
70
+ return withoutConcurrency(this.sampleTag, async () => {
71
+ const stats = await this.pc.getStats();
72
+ const now = Date.now();
73
+ const currentStats = toObjectWithCorrectedTimestamp(
74
+ stats,
75
+ now,
76
+ this.driftThresholdMs,
77
+ );
78
+
79
+ // Sustained delivery failure: drop the stale un-acked chain and re-anchor
80
+ // so the next delta is a full snapshot. A full snapshot overwrites the
81
+ // server's accumulator, re-syncing it, and keeps the payload bounded.
82
+ if (this.pendingDeltas.length >= this.maxPendingDeltas) {
83
+ this.pendingDeltas = [];
84
+ this.previousSample = {};
85
+ }
86
+
87
+ const performanceStats = this.withOverrides(
88
+ this.peerType === PeerType.SUBSCRIBER
89
+ ? this.getDecodeStats(currentStats)
90
+ : this.getEncodeStats(currentStats),
91
+ );
92
+
93
+ const delta = deltaCompression(this.previousSample, currentStats);
94
+
95
+ // store the current data for the next iteration
96
+ this.previousSample = currentStats;
97
+ this.pendingDeltas.push({ delta, ts: now });
98
+ this.frameTimeHistory = this.frameTimeHistory.slice(-2);
99
+ this.fpsHistory = this.fpsHistory.slice(-2);
100
+
101
+ return { performanceStats, stats };
102
+ });
103
+ };
104
+
105
+ /**
106
+ * Returns a stable copy of the un-acked delta chain to transmit, oldest first.
107
+ *
108
+ * @internal
109
+ */
110
+ getPendingDeltas = (): PendingDelta[] => this.pendingDeltas.slice();
111
+
112
+ /**
113
+ * Advances the delivery baseline by dropping exactly the deltas that were
114
+ * confirmed delivered. Matching is by object identity, so a stale commit
115
+ * that arrives after a re-anchor (which replaced the chain) is a safe no-op.
116
+ *
117
+ * @internal
118
+ */
119
+ commitDeltas = (sent: PendingDelta[]): void => {
120
+ if (sent.length === 0) return;
121
+ const committed = new Set(sent);
122
+ this.pendingDeltas = this.pendingDeltas.filter((d) => !committed.has(d));
123
+ };
124
+
125
+ /**
126
+ * Drops the un-acked chain without sending it. Used when delta reporting is
127
+ * disabled so the chain can't grow unbounded.
50
128
  *
51
129
  * @internal
52
130
  */
53
- get = async (): Promise<ComputedStats> => {
54
- const stats = await this.pc.getStats();
55
- const currentStats = toObjectWithCorrectedTimestamp(
56
- stats,
57
- Date.now(),
58
- this.driftThresholdMs,
59
- );
60
-
61
- const performanceStats = this.withOverrides(
62
- this.peerType === PeerType.SUBSCRIBER
63
- ? this.getDecodeStats(currentStats)
64
- : this.getEncodeStats(currentStats),
65
- );
66
-
67
- const delta = deltaCompression(this.previousStats, currentStats);
68
-
69
- // store the current data for the next iteration
70
- this.previousStats = currentStats;
71
- this.frameTimeHistory = this.frameTimeHistory.slice(-2);
72
- this.fpsHistory = this.fpsHistory.slice(-2);
73
-
74
- return { performanceStats, delta, stats };
131
+ clearPendingDeltas = (): void => {
132
+ this.pendingDeltas = [];
75
133
  };
76
134
 
77
135
  /**
@@ -97,8 +155,8 @@ export class StatsTracer {
97
155
  mediaSourceId,
98
156
  } = rtp as RTCOutboundRtpStreamStats;
99
157
 
100
- if (kind === 'audio' || !this.previousStats[id]) continue;
101
- const prevRtp = this.previousStats[id] as RTCOutboundRtpStreamStats;
158
+ if (kind === 'audio' || !this.previousSample[id]) continue;
159
+ const prevRtp = this.previousSample[id] as RTCOutboundRtpStreamStats;
102
160
 
103
161
  const deltaTotalEncodeTime =
104
162
  totalEncodeTime - (prevRtp.totalEncodeTime || 0);
@@ -150,8 +208,8 @@ export class StatsTracer {
150
208
  }
151
209
  }
152
210
 
153
- if (!rtp || !this.previousStats[rtp.id]) return [];
154
- const prevRtp = this.previousStats[rtp.id] as RTCInboundRtpStreamStats;
211
+ if (!rtp || !this.previousSample[rtp.id]) return [];
212
+ const prevRtp = this.previousSample[rtp.id] as RTCInboundRtpStreamStats;
155
213
 
156
214
  const {
157
215
  framesDecoded = 0,