@stream-io/video-client 1.61.0 → 1.61.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/CHANGELOG.md +15 -0
- package/dist/index.browser.es.js +292 -154
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +292 -154
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +292 -154
- package/dist/index.es.js.map +1 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +0 -3
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +20 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +84 -9
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +8 -3
- package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +6 -1
- package/src/rtc/__tests__/Publisher.test.ts +50 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- package/src/stats/rtc/types.ts +0 -13
|
@@ -202,6 +202,39 @@ describe('reconcileRingState', () => {
|
|
|
202
202
|
message: 'ring: call ended',
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
describe('as the callee', () => {
|
|
207
|
+
const ended = { reason: 'ended', message: 'ring: call ended' };
|
|
208
|
+
|
|
209
|
+
it('leaves when the session has ended', async () => {
|
|
210
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
211
|
+
setSession(call, { ended_at: timestamp() });
|
|
212
|
+
|
|
213
|
+
expect(await reconcile(call)).toBe(true);
|
|
214
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('leaves when the call itself has ended', async () => {
|
|
218
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
219
|
+
setSession(call, {});
|
|
220
|
+
call.state.setEndedAt(new Date());
|
|
221
|
+
|
|
222
|
+
expect(await reconcile(call)).toBe(true);
|
|
223
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('leaves once with the ended message when the creator also rejected', async () => {
|
|
227
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
228
|
+
setSession(call, {
|
|
229
|
+
ended_at: timestamp(),
|
|
230
|
+
rejected_by: { m1: timestamp() },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(await reconcile(call)).toBe(true);
|
|
234
|
+
expect(call.leave).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
205
238
|
});
|
|
206
239
|
|
|
207
240
|
it('is terminal once the call is no longer ringing', async () => {
|
|
@@ -6,15 +6,16 @@ import type { CallLeaveOptions } from '../types';
|
|
|
6
6
|
/**
|
|
7
7
|
* Decides what a ringing call should do next, based on the current call state.
|
|
8
8
|
*
|
|
9
|
-
* The `call.accepted
|
|
10
|
-
*
|
|
11
|
-
* handlers rely on `CallState.updateFromEvent`, which runs
|
|
12
|
-
* poller applies the polled ring state itself
|
|
9
|
+
* The `call.accepted` and `call.rejected` handlers, the ring state poller and
|
|
10
|
+
* the rewatch after a WS reconnect all run this. They differ only in how the
|
|
11
|
+
* state got there: the handlers rely on `CallState.updateFromEvent`, which runs
|
|
12
|
+
* before them, while the poller applies the polled ring state itself and the
|
|
13
|
+
* rewatch applies the queried call state.
|
|
13
14
|
*
|
|
14
15
|
* @param call the call to reconcile.
|
|
15
|
-
* @param joinSource
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* @param joinSource what triggered this run, reported on the caller's join:
|
|
17
|
+
* `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
|
|
18
|
+
* rewatch (both come from an API response).
|
|
18
19
|
* @returns whether the ring reached a terminal state. A failed join is not
|
|
19
20
|
* terminal: the caller should keep trying while the ring is open.
|
|
20
21
|
*/
|
|
@@ -79,10 +80,17 @@ const reconcileAsCaller = async (
|
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
// the current user's own accept or reject, on this or another device, is
|
|
82
|
-
// handled by `resolveOwnRingOutcome`, and `call.ended` by
|
|
83
|
+
// handled by `resolveOwnRingOutcome`, and a live `call.ended` by
|
|
84
|
+
// `watchCallEnded`. An end missed while offline is handled here.
|
|
83
85
|
const reconcileAsCallee = async (call: Call): Promise<boolean> => {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
+
const { session, endedAt, createdBy } = call.state;
|
|
87
|
+
if (endedAt || session?.ended_at) {
|
|
88
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
89
|
+
return leave(call, { reason: 'ended', message: 'ring: call ended' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const createdById = createdBy?.id;
|
|
93
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
86
94
|
if (createdById && rejectedBy[createdById]) {
|
|
87
95
|
call.logger.info('ring: the caller cancelled, leaving');
|
|
88
96
|
return leave(call, {
|
|
@@ -344,9 +344,14 @@ export abstract class BasePeerConnection {
|
|
|
344
344
|
|
|
345
345
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
346
346
|
if (state === 'failed') {
|
|
347
|
+
const reason = this.iceHasEverConnected
|
|
348
|
+
? ReconnectReason.CONNECTION_FAILED
|
|
349
|
+
: ReconnectReason.ICE_NEVER_CONNECTED;
|
|
350
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
351
|
+
this.preConnectStuckTimeout = undefined;
|
|
347
352
|
this.onReconnectionNeeded?.(
|
|
348
353
|
WebsocketReconnectStrategy.REJOIN,
|
|
349
|
-
|
|
354
|
+
reason,
|
|
350
355
|
this.peerType,
|
|
351
356
|
);
|
|
352
357
|
return;
|
|
@@ -931,6 +931,56 @@ describe('Publisher', () => {
|
|
|
931
931
|
);
|
|
932
932
|
});
|
|
933
933
|
|
|
934
|
+
it.each(['checking', 'failed'] as const)(
|
|
935
|
+
`connection-state 'failed' before ICE connects emits ICE_NEVER_CONNECTED when ICE is '%s'`,
|
|
936
|
+
async (iceState) => {
|
|
937
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
938
|
+
publisher['onReconnectionNeeded'] = vi.fn();
|
|
939
|
+
// @ts-expect-error readonly field
|
|
940
|
+
publisher['pc'].iceConnectionState = iceState;
|
|
941
|
+
// @ts-expect-error readonly field
|
|
942
|
+
publisher['pc'].connectionState = 'failed';
|
|
943
|
+
|
|
944
|
+
// No ICE state-change event precedes the aggregate failure.
|
|
945
|
+
await publisher['onConnectionStateChange']();
|
|
946
|
+
|
|
947
|
+
expect(publisher.restartIce).not.toHaveBeenCalled();
|
|
948
|
+
expect(
|
|
949
|
+
publisher['onReconnectionNeeded'],
|
|
950
|
+
).toHaveBeenCalledExactlyOnceWith(
|
|
951
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
952
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
953
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
954
|
+
);
|
|
955
|
+
},
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
it(`connection-state 'failed' cancels the pre-connect watchdog`, async () => {
|
|
959
|
+
vi.useFakeTimers();
|
|
960
|
+
publisher['onReconnectionNeeded'] = vi.fn();
|
|
961
|
+
const watchdogMs = publisher['iceRestartDelay'] * 2;
|
|
962
|
+
|
|
963
|
+
// @ts-expect-error readonly field
|
|
964
|
+
publisher['pc'].iceConnectionState = 'disconnected';
|
|
965
|
+
publisher['onIceConnectionStateChange']();
|
|
966
|
+
expect(publisher['preConnectStuckTimeout']).toBeDefined();
|
|
967
|
+
expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
|
|
968
|
+
|
|
969
|
+
// Keep ICE disconnected so an uncanceled watchdog would reconnect again.
|
|
970
|
+
// @ts-expect-error readonly field
|
|
971
|
+
publisher['pc'].connectionState = 'failed';
|
|
972
|
+
await publisher['onConnectionStateChange']();
|
|
973
|
+
|
|
974
|
+
expect(publisher['preConnectStuckTimeout']).toBeUndefined();
|
|
975
|
+
expect(publisher['onReconnectionNeeded']).toHaveBeenCalledExactlyOnceWith(
|
|
976
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
977
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
978
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
979
|
+
);
|
|
980
|
+
await vi.advanceTimersByTimeAsync(watchdogMs + 100);
|
|
981
|
+
expect(publisher['onReconnectionNeeded']).toHaveBeenCalledTimes(1);
|
|
982
|
+
});
|
|
983
|
+
|
|
934
984
|
it(`connection-state 'failed' (distinct from ICE state) still fires REJOIN even after ICE was connected`, () => {
|
|
935
985
|
// mark ICE as connected first
|
|
936
986
|
// @ts-expect-error private api
|
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import { combineLatest } from 'rxjs';
|
|
2
1
|
import { StreamSfuClient } from '../StreamSfuClient';
|
|
3
|
-
import {
|
|
2
|
+
import { StatsOptions } from '../gen/coordinator';
|
|
4
3
|
import { Publisher, Subscriber } from '../rtc';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { PendingDelta, Tracer, TraceRecord } from './rtc';
|
|
5
|
+
import { getSdkName, getSdkVersion } from './utils';
|
|
7
6
|
import { getDeviceState, getWebRTCInfo } from '../helpers/client-details';
|
|
8
7
|
import { hasPending, withoutConcurrency } from '../helpers/concurrency';
|
|
9
8
|
import { timeboxed } from '../coordinator/connection/utils';
|
|
10
9
|
import {
|
|
11
10
|
ClientDetails,
|
|
12
|
-
|
|
11
|
+
PerformanceStats,
|
|
13
12
|
WebsocketReconnectStrategy,
|
|
14
13
|
} from '../gen/video/sfu/models/models';
|
|
15
|
-
import { CameraManager, MicrophoneManager } from '../devices';
|
|
16
|
-
import { createSubscription } from '../store/rxUtils';
|
|
17
|
-
import { CallState } from '../store';
|
|
18
14
|
import { Telemetry } from '../gen/video/sfu/signal_rpc/signal';
|
|
19
15
|
import { videoLoggerSystem } from '../logger';
|
|
20
16
|
|
|
@@ -23,9 +19,6 @@ export type SfuStatsReporterOptions = {
|
|
|
23
19
|
clientDetails: ClientDetails;
|
|
24
20
|
subscriber: Subscriber;
|
|
25
21
|
publisher?: Publisher;
|
|
26
|
-
microphone: MicrophoneManager;
|
|
27
|
-
camera: CameraManager;
|
|
28
|
-
state: CallState;
|
|
29
22
|
tracer: Tracer;
|
|
30
23
|
unifiedSessionId: string;
|
|
31
24
|
};
|
|
@@ -38,21 +31,15 @@ export class SfuStatsReporter {
|
|
|
38
31
|
private readonly sfuClient: StreamSfuClient;
|
|
39
32
|
private readonly subscriber: Subscriber;
|
|
40
33
|
private readonly publisher?: Publisher;
|
|
41
|
-
private readonly microphone: MicrophoneManager;
|
|
42
|
-
private readonly camera: CameraManager;
|
|
43
|
-
private readonly state: CallState;
|
|
44
34
|
private readonly tracer: Tracer;
|
|
45
35
|
private readonly unifiedSessionId: string;
|
|
46
36
|
|
|
47
37
|
private intervalId: NodeJS.Timeout | undefined;
|
|
48
38
|
private timeoutId: NodeJS.Timeout | undefined;
|
|
49
39
|
private reportCount: number = 0;
|
|
50
|
-
private unsubscribeDevicePermissionsSubscription?: () => void;
|
|
51
|
-
private unsubscribeListDevicesSubscription?: () => void;
|
|
52
40
|
private readonly sdkName: string;
|
|
53
41
|
private readonly sdkVersion: string;
|
|
54
42
|
private readonly webRTCVersion: string;
|
|
55
|
-
private readonly inputDevices = new Map<'mic' | 'camera', InputDevices>();
|
|
56
43
|
private readonly statsConcurrencyTag = Symbol('sfuStatsReporter');
|
|
57
44
|
private isStopped = false;
|
|
58
45
|
|
|
@@ -63,9 +50,6 @@ export class SfuStatsReporter {
|
|
|
63
50
|
clientDetails,
|
|
64
51
|
subscriber,
|
|
65
52
|
publisher,
|
|
66
|
-
microphone,
|
|
67
|
-
camera,
|
|
68
|
-
state,
|
|
69
53
|
tracer,
|
|
70
54
|
unifiedSessionId,
|
|
71
55
|
}: SfuStatsReporterOptions,
|
|
@@ -74,9 +58,6 @@ export class SfuStatsReporter {
|
|
|
74
58
|
this.options = options;
|
|
75
59
|
this.subscriber = subscriber;
|
|
76
60
|
this.publisher = publisher;
|
|
77
|
-
this.microphone = microphone;
|
|
78
|
-
this.camera = camera;
|
|
79
|
-
this.state = state;
|
|
80
61
|
this.tracer = tracer;
|
|
81
62
|
this.unifiedSessionId = unifiedSessionId;
|
|
82
63
|
|
|
@@ -93,46 +74,6 @@ export class SfuStatsReporter {
|
|
|
93
74
|
'N/A';
|
|
94
75
|
}
|
|
95
76
|
|
|
96
|
-
private observeDevice = (
|
|
97
|
-
device: CameraManager | MicrophoneManager,
|
|
98
|
-
kind: 'mic' | 'camera',
|
|
99
|
-
) => {
|
|
100
|
-
const { browserPermissionState$ } = device.state;
|
|
101
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
102
|
-
this.unsubscribeDevicePermissionsSubscription = createSubscription(
|
|
103
|
-
combineLatest([browserPermissionState$, this.state.ownCapabilities$]),
|
|
104
|
-
([browserPermissionState, ownCapabilities]) => {
|
|
105
|
-
// cleanup the previous listDevices() subscription in case
|
|
106
|
-
// permissions or capabilities have changed.
|
|
107
|
-
// we will subscribe again if everything is in order.
|
|
108
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
109
|
-
const hasCapability =
|
|
110
|
-
kind === 'mic'
|
|
111
|
-
? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
|
|
112
|
-
: ownCapabilities.includes(OwnCapability.SEND_VIDEO);
|
|
113
|
-
if (browserPermissionState !== 'granted' || !hasCapability) {
|
|
114
|
-
this.inputDevices.set(kind, {
|
|
115
|
-
currentDevice: '',
|
|
116
|
-
availableDevices: [],
|
|
117
|
-
isPermitted: false,
|
|
118
|
-
});
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
this.unsubscribeListDevicesSubscription = createSubscription(
|
|
122
|
-
combineLatest([device.listDevices(), device.state.selectedDevice$]),
|
|
123
|
-
([devices, deviceId]) => {
|
|
124
|
-
const selected = devices.find((d) => d.deviceId === deviceId);
|
|
125
|
-
this.inputDevices.set(kind, {
|
|
126
|
-
currentDevice: selected?.label || deviceId || '',
|
|
127
|
-
availableDevices: devices.map((d) => d.label),
|
|
128
|
-
isPermitted: true,
|
|
129
|
-
});
|
|
130
|
-
},
|
|
131
|
-
);
|
|
132
|
-
},
|
|
133
|
-
);
|
|
134
|
-
};
|
|
135
|
-
|
|
136
77
|
sendConnectionTime = (connectionTimeSeconds: number) => {
|
|
137
78
|
this.sendTelemetryData({
|
|
138
79
|
data: {
|
|
@@ -169,15 +110,17 @@ export class SfuStatsReporter {
|
|
|
169
110
|
* explicit flush can capture the sample from live peer connections before
|
|
170
111
|
* they are disposed, without waiting for an in-flight send.
|
|
171
112
|
*/
|
|
172
|
-
private sample = (): Promise<
|
|
113
|
+
private sample = (): Promise<
|
|
114
|
+
[PerformanceStats[], PerformanceStats[] | undefined]
|
|
115
|
+
> =>
|
|
173
116
|
Promise.all([
|
|
174
117
|
this.subscriber.stats.takeSample(),
|
|
175
118
|
this.publisher?.stats.takeSample(),
|
|
176
119
|
]);
|
|
177
120
|
|
|
178
121
|
private send = (
|
|
179
|
-
|
|
180
|
-
|
|
122
|
+
subscriberPerfStats: PerformanceStats[],
|
|
123
|
+
publisherPerfStats: PerformanceStats[] | undefined,
|
|
181
124
|
telemetry?: Telemetry,
|
|
182
125
|
) => {
|
|
183
126
|
// serialize sends so overlapping ones can't race on the trace buffers or
|
|
@@ -196,8 +139,8 @@ export class SfuStatsReporter {
|
|
|
196
139
|
this.subscriber.stats.clearPendingDeltas();
|
|
197
140
|
}
|
|
198
141
|
let pubPending: PendingDelta[] = [];
|
|
199
|
-
if (pubTracer &&
|
|
200
|
-
pubPending = this.publisher
|
|
142
|
+
if (pubTracer && this.publisher) {
|
|
143
|
+
pubPending = this.publisher.stats.getPendingDeltas();
|
|
201
144
|
} else {
|
|
202
145
|
this.publisher?.stats.clearPendingDeltas();
|
|
203
146
|
}
|
|
@@ -220,17 +163,9 @@ export class SfuStatsReporter {
|
|
|
220
163
|
sdk: this.sdkName,
|
|
221
164
|
sdkVersion: this.sdkVersion,
|
|
222
165
|
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
166
|
rtcStats: JSON.stringify(traces),
|
|
230
|
-
encodeStats:
|
|
231
|
-
decodeStats:
|
|
232
|
-
audioDevices: this.inputDevices.get('mic'),
|
|
233
|
-
videoDevices: this.inputDevices.get('camera'),
|
|
167
|
+
encodeStats: publisherPerfStats ?? [],
|
|
168
|
+
decodeStats: subscriberPerfStats,
|
|
234
169
|
unifiedSessionId: this.unifiedSessionId,
|
|
235
170
|
deviceState: getDeviceState(),
|
|
236
171
|
telemetry,
|
|
@@ -243,8 +178,8 @@ export class SfuStatsReporter {
|
|
|
243
178
|
}
|
|
244
179
|
// delivery confirmed: advance the delivery baseline for each chain.
|
|
245
180
|
if (subTracer) this.subscriber.stats.commitDeltas(subPending);
|
|
246
|
-
if (pubTracer &&
|
|
247
|
-
this.publisher
|
|
181
|
+
if (pubTracer && this.publisher) {
|
|
182
|
+
this.publisher.stats.commitDeltas(pubPending);
|
|
248
183
|
}
|
|
249
184
|
} catch (err) {
|
|
250
185
|
// keep the delta chains (re-sent next interval); only the append-only
|
|
@@ -265,8 +200,8 @@ export class SfuStatsReporter {
|
|
|
265
200
|
*/
|
|
266
201
|
private run = async (telemetry?: Telemetry) => {
|
|
267
202
|
if (this.isStopped) return;
|
|
268
|
-
const [
|
|
269
|
-
await this.send(
|
|
203
|
+
const [subscriberPerfStats, publisherPerfStats] = await this.sample();
|
|
204
|
+
await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
|
|
270
205
|
};
|
|
271
206
|
|
|
272
207
|
private scheduleNextReport = () => {
|
|
@@ -288,9 +223,6 @@ export class SfuStatsReporter {
|
|
|
288
223
|
start = () => {
|
|
289
224
|
if (this.options.reporting_interval_ms <= 0) return;
|
|
290
225
|
|
|
291
|
-
this.observeDevice(this.microphone, 'mic');
|
|
292
|
-
this.observeDevice(this.camera, 'camera');
|
|
293
|
-
|
|
294
226
|
this.isStopped = false;
|
|
295
227
|
this.reportCount = 0;
|
|
296
228
|
clearInterval(this.intervalId);
|
|
@@ -301,12 +233,6 @@ export class SfuStatsReporter {
|
|
|
301
233
|
|
|
302
234
|
stop = () => {
|
|
303
235
|
this.isStopped = true;
|
|
304
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
305
|
-
this.unsubscribeDevicePermissionsSubscription = undefined;
|
|
306
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
307
|
-
this.unsubscribeListDevicesSubscription = undefined;
|
|
308
|
-
|
|
309
|
-
this.inputDevices.clear();
|
|
310
236
|
clearInterval(this.intervalId);
|
|
311
237
|
this.intervalId = undefined;
|
|
312
238
|
clearTimeout(this.timeoutId);
|
|
@@ -327,8 +253,8 @@ export class SfuStatsReporter {
|
|
|
327
253
|
if (this.isStopped) return;
|
|
328
254
|
try {
|
|
329
255
|
const [sample] = await timeboxed([this.sample()], 2000);
|
|
330
|
-
const [
|
|
331
|
-
this.send(
|
|
256
|
+
const [subscriberPerfStats, publisherPerfStats] = sample;
|
|
257
|
+
this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
|
|
332
258
|
this.logger.warn('Failed to flush report stats', err);
|
|
333
259
|
});
|
|
334
260
|
} catch (err) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { BehaviorSubject } from 'rxjs';
|
|
3
2
|
import { SfuStatsReporter } from '../SfuStatsReporter';
|
|
4
3
|
import { promiseWithResolvers } from '../../helpers/promise';
|
|
5
4
|
|
|
@@ -9,10 +8,7 @@ const okResponse = { response: {} };
|
|
|
9
8
|
const makeSlice = () => ({ snapshot: [] as unknown[], rollback: vi.fn() });
|
|
10
9
|
|
|
11
10
|
const makeStats = (pending: Array<{ delta: object; ts: number }>) => ({
|
|
12
|
-
takeSample: vi.fn().mockResolvedValue(
|
|
13
|
-
performanceStats: [],
|
|
14
|
-
stats: new Map(),
|
|
15
|
-
}),
|
|
11
|
+
takeSample: vi.fn().mockResolvedValue([]),
|
|
16
12
|
getPendingDeltas: vi.fn(() => pending),
|
|
17
13
|
commitDeltas: vi.fn(),
|
|
18
14
|
clearPendingDeltas: vi.fn(),
|
|
@@ -49,23 +45,11 @@ const build = (
|
|
|
49
45
|
const sfuClient = { sendStats, getTrace: vi.fn(() => undefined) };
|
|
50
46
|
const callTracer = { take: vi.fn(makeSlice), setEnabled: vi.fn() };
|
|
51
47
|
|
|
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
48
|
const reporter = new SfuStatsReporter(sfuClient as never, {
|
|
62
49
|
options: { reporting_interval_ms: 1000, enable_rtc_stats: true } as never,
|
|
63
50
|
clientDetails: { sdk: undefined, browser: undefined } as never,
|
|
64
51
|
subscriber: subscriber as never,
|
|
65
52
|
publisher: publisher as never,
|
|
66
|
-
microphone: microphone as never,
|
|
67
|
-
camera: camera as never,
|
|
68
|
-
state: state as never,
|
|
69
53
|
tracer: callTracer as never,
|
|
70
54
|
unifiedSessionId: 'unified',
|
|
71
55
|
});
|
|
@@ -143,7 +127,7 @@ describe('SfuStatsReporter delta delivery', () => {
|
|
|
143
127
|
await Promise.resolve();
|
|
144
128
|
expect(resolved).toBe(false); // still sampling -> flush not resolved
|
|
145
129
|
|
|
146
|
-
dGet.resolve(
|
|
130
|
+
dGet.resolve([]);
|
|
147
131
|
await flushed;
|
|
148
132
|
expect(resolved).toBe(true); // resolves after the sample, not the send
|
|
149
133
|
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
TrackType,
|
|
6
6
|
} from '../../gen/video/sfu/models/models';
|
|
7
7
|
import type { RTCCodecStats, RTCMediaSourceStats } from '../types';
|
|
8
|
-
import type {
|
|
8
|
+
import type { PendingDelta } from './types';
|
|
9
9
|
import { withoutConcurrency } from '../../helpers/concurrency';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -60,13 +60,13 @@ export class StatsTracer {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Samples the RTCPeerConnection: returns the
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* Samples the RTCPeerConnection: returns the derived performance stats and
|
|
64
|
+
* appends the delta-compressed sample to the un-acked delivery chain
|
|
65
|
+
* (retrieved via `getPendingDeltas`).
|
|
66
66
|
*
|
|
67
67
|
* @internal
|
|
68
68
|
*/
|
|
69
|
-
takeSample = (): Promise<
|
|
69
|
+
takeSample = (): Promise<PerformanceStats[]> => {
|
|
70
70
|
return withoutConcurrency(this.sampleTag, async () => {
|
|
71
71
|
const stats = await this.pc.getStats();
|
|
72
72
|
const now = Date.now();
|
|
@@ -98,7 +98,7 @@ export class StatsTracer {
|
|
|
98
98
|
this.frameTimeHistory = this.frameTimeHistory.slice(-2);
|
|
99
99
|
this.fpsHistory = this.fpsHistory.slice(-2);
|
|
100
100
|
|
|
101
|
-
return
|
|
101
|
+
return performanceStats;
|
|
102
102
|
});
|
|
103
103
|
};
|
|
104
104
|
|
package/src/stats/rtc/types.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { PerformanceStats } from '../../gen/video/sfu/models/models';
|
|
2
|
-
|
|
3
1
|
export type RTCStatsDataType =
|
|
4
2
|
| RTCConfiguration
|
|
5
3
|
| RTCIceCandidate
|
|
@@ -41,17 +39,6 @@ export type TraceSlice = {
|
|
|
41
39
|
rollback: () => void;
|
|
42
40
|
};
|
|
43
41
|
|
|
44
|
-
export type ComputedStats = {
|
|
45
|
-
/**
|
|
46
|
-
* Current stats from the RTCPeerConnection.
|
|
47
|
-
*/
|
|
48
|
-
stats: RTCStatsReport;
|
|
49
|
-
/**
|
|
50
|
-
* The current iteration of the stats.
|
|
51
|
-
*/
|
|
52
|
-
performanceStats: PerformanceStats[];
|
|
53
|
-
};
|
|
54
|
-
|
|
55
42
|
/**
|
|
56
43
|
* A single, not-yet-delivered delta-compressed stats sample.
|
|
57
44
|
* The `delta` is computed against the immediately preceding sample, so a
|