@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.
- package/CHANGELOG.md +14 -0
- package/dist/index.browser.es.js +9641 -8767
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9638 -8764
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9639 -8765
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +13 -1
- package/dist/src/StreamSfuClient.d.ts +11 -3
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
- package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
- package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
- package/dist/src/rtc/Publisher.d.ts +5 -2
- package/dist/src/rtc/Subscriber.d.ts +8 -0
- package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
- package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
- package/dist/src/stats/rtc/Tracer.d.ts +9 -2
- package/dist/src/stats/rtc/types.d.ts +10 -4
- package/package.json +5 -3
- package/src/Call.ts +83 -35
- package/src/StreamSfuClient.ts +36 -21
- package/src/__tests__/StreamSfuClient.test.ts +159 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
- package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
- package/src/coordinator/connection/connection.ts +8 -5
- package/src/gen/google/protobuf/struct.ts +7 -12
- package/src/gen/google/protobuf/timestamp.ts +6 -7
- package/src/gen/video/sfu/event/events.ts +22 -25
- package/src/gen/video/sfu/models/models.ts +10 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
- package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
- package/src/helpers/__tests__/browsers.test.ts +12 -12
- package/src/helpers/browsers.ts +5 -5
- package/src/reporting/ClientEventReporter.ts +17 -12
- package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
- package/src/rtc/BasePeerConnection.ts +15 -34
- package/src/rtc/IceTrickleBuffer.ts +105 -12
- package/src/rtc/Publisher.ts +23 -19
- package/src/rtc/Subscriber.ts +97 -36
- package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
- package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
- package/src/rtc/__tests__/Publisher.test.ts +2 -31
- package/src/rtc/__tests__/Subscriber.test.ts +271 -20
- package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
- package/src/rtc/helpers/degradationPreference.ts +1 -0
- package/src/rtc/helpers/iceCandiates.ts +35 -0
- package/src/rtc/helpers/sdp.ts +3 -2
- package/src/rtc/helpers/tracks.ts +2 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/SfuStatsReporter.ts +149 -49
- package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
- package/src/stats/rtc/StatsTracer.ts +90 -32
- package/src/stats/rtc/Tracer.ts +23 -2
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
- package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
- 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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
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.
|
|
101
|
-
const prevRtp = this.
|
|
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.
|
|
154
|
-
const prevRtp = this.
|
|
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,
|