livekit-client 2.22.3 → 2.22.4
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 +35 -0
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +2 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +602 -247
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +2 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts +4 -2
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/options.d.ts +14 -0
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +78 -7
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +8 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +1 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/defaults.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts +18 -0
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +3 -2
- package/dist/src/room/events.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +2 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/utils.d.ts +13 -0
- package/dist/src/room/track/utils.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +32 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/compressionStreamStubs.d.ts +12 -0
- package/dist/src/test/compressionStreamStubs.d.ts.map +1 -0
- package/dist/ts4.2/api/SignalClient.d.ts +187 -185
- package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -85
- package/dist/ts4.2/api/WebSocketStream.d.ts +33 -33
- package/dist/ts4.2/api/utils.d.ts +9 -9
- package/dist/ts4.2/connectionHelper/ConnectionCheck.d.ts +30 -30
- package/dist/ts4.2/connectionHelper/checks/Checker.d.ts +62 -62
- package/dist/ts4.2/connectionHelper/checks/cloudRegion.d.ts +18 -18
- package/dist/ts4.2/connectionHelper/checks/connectionProtocol.d.ts +20 -20
- package/dist/ts4.2/connectionHelper/checks/publishAudio.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/publishVideo.d.ts +7 -7
- package/dist/ts4.2/connectionHelper/checks/reconnect.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/turn.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/webrtc.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/websocket.d.ts +6 -6
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +111 -111
- package/dist/ts4.2/e2ee/KeyProvider.d.ts +54 -54
- package/dist/ts4.2/e2ee/constants.d.ts +21 -21
- package/dist/ts4.2/e2ee/errors.d.ts +12 -12
- package/dist/ts4.2/e2ee/events.d.ts +39 -39
- package/dist/ts4.2/e2ee/index.d.ts +6 -6
- package/dist/ts4.2/e2ee/types.d.ts +231 -231
- package/dist/ts4.2/e2ee/utils.d.ts +32 -32
- package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +16 -16
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -21
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +209 -209
- package/dist/ts4.2/e2ee/worker/ParticipantKeyHandler.d.ts +81 -84
- package/dist/ts4.2/e2ee/worker/e2ee.worker.d.ts +2 -2
- package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +28 -28
- package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +23 -23
- package/dist/ts4.2/frameMetadata/FrameMetadataManager.d.ts +53 -53
- package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +33 -33
- package/dist/ts4.2/frameMetadata/types.d.ts +62 -62
- package/dist/ts4.2/frameMetadata/utils.d.ts +9 -9
- package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -2
- package/dist/ts4.2/index.d.ts +76 -76
- package/dist/ts4.2/logger.d.ts +66 -66
- package/dist/ts4.2/options.d.ts +163 -149
- package/dist/ts4.2/room/BackOffStrategy.d.ts +27 -27
- package/dist/ts4.2/room/DefaultReconnectPolicy.d.ts +8 -8
- package/dist/ts4.2/room/DeviceManager.d.ts +12 -12
- package/dist/ts4.2/room/PCTransport.d.ts +246 -175
- package/dist/ts4.2/room/PCTransportManager.d.ts +77 -77
- package/dist/ts4.2/room/RTCEngine.d.ts +269 -262
- package/dist/ts4.2/room/ReconnectPolicy.d.ts +24 -24
- package/dist/ts4.2/room/RegionUrlProvider.d.ts +36 -36
- package/dist/ts4.2/room/Room.d.ts +354 -365
- package/dist/ts4.2/room/attribute-typings.d.ts +35 -35
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -62
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +94 -95
- package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -41
- package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -57
- package/dist/ts4.2/room/data-channel/types.d.ts +12 -12
- package/dist/ts4.2/room/data-stream/compression.d.ts +49 -49
- package/dist/ts4.2/room/data-stream/constants.d.ts +18 -18
- package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +29 -29
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +80 -80
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +73 -73
- package/dist/ts4.2/room/data-stream/outgoing/StreamWriter.d.ts +16 -16
- package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -18
- package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +79 -80
- package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +50 -50
- package/dist/ts4.2/room/data-track/depacketizer.d.ts +59 -59
- package/dist/ts4.2/room/data-track/frame.d.ts +22 -22
- package/dist/ts4.2/room/data-track/handle.d.ts +27 -27
- package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +124 -124
- package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -24
- package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +41 -41
- package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -20
- package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +126 -126
- package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +74 -74
- package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +23 -23
- package/dist/ts4.2/room/data-track/outgoing/types.d.ts +48 -48
- package/dist/ts4.2/room/data-track/packet/constants.d.ts +20 -20
- package/dist/ts4.2/room/data-track/packet/errors.d.ts +40 -40
- package/dist/ts4.2/room/data-track/packet/extensions.d.ts +69 -69
- package/dist/ts4.2/room/data-track/packet/index.d.ts +98 -98
- package/dist/ts4.2/room/data-track/packet/serializable.d.ts +13 -13
- package/dist/ts4.2/room/data-track/packetizer.d.ts +43 -43
- package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -23
- package/dist/ts4.2/room/data-track/types.d.ts +22 -22
- package/dist/ts4.2/room/data-track/utils.d.ts +60 -60
- package/dist/ts4.2/room/debounce.d.ts +11 -11
- package/dist/ts4.2/room/defaults.d.ts +9 -9
- package/dist/ts4.2/room/errors.d.ts +180 -162
- package/dist/ts4.2/room/events.d.ts +638 -637
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +287 -287
- package/dist/ts4.2/room/participant/Participant.d.ts +142 -144
- package/dist/ts4.2/room/participant/ParticipantTrackPermission.d.ts +26 -26
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +85 -86
- package/dist/ts4.2/room/participant/publishUtils.d.ts +53 -53
- package/dist/ts4.2/room/rpc/client/RpcClientManager.d.ts +43 -43
- package/dist/ts4.2/room/rpc/client/events.d.ts +8 -8
- package/dist/ts4.2/room/rpc/index.d.ts +7 -7
- package/dist/ts4.2/room/rpc/server/RpcServerManager.d.ts +44 -44
- package/dist/ts4.2/room/rpc/server/events.d.ts +8 -8
- package/dist/ts4.2/room/rpc/utils.d.ts +131 -131
- package/dist/ts4.2/room/stats.d.ts +71 -71
- package/dist/ts4.2/room/statsSummary.d.ts +13 -13
- package/dist/ts4.2/room/timers.d.ts +12 -12
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +82 -82
- package/dist/ts4.2/room/token-source/types.d.ts +70 -70
- package/dist/ts4.2/room/token-source/utils.d.ts +8 -8
- package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -19
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +39 -42
- package/dist/ts4.2/room/track/LocalTrack.d.ts +125 -125
- package/dist/ts4.2/room/track/LocalTrackPublication.d.ts +42 -42
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +102 -100
- package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +55 -55
- package/dist/ts4.2/room/track/RemoteTrack.d.ts +38 -38
- package/dist/ts4.2/room/track/RemoteTrackPublication.d.ts +72 -75
- package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +73 -76
- package/dist/ts4.2/room/track/Track.d.ts +156 -166
- package/dist/ts4.2/room/track/TrackPublication.d.ts +75 -81
- package/dist/ts4.2/room/track/create.d.ts +25 -25
- package/dist/ts4.2/room/track/facingMode.d.ts +41 -41
- package/dist/ts4.2/room/track/options.d.ts +403 -403
- package/dist/ts4.2/room/track/processor/types.d.ts +31 -31
- package/dist/ts4.2/room/track/record.d.ts +13 -13
- package/dist/ts4.2/room/track/types.d.ts +28 -28
- package/dist/ts4.2/room/track/utils.d.ts +60 -47
- package/dist/ts4.2/room/types.d.ts +127 -127
- package/dist/ts4.2/room/utils.d.ts +246 -214
- package/dist/ts4.2/test/MockMediaStreamTrack.d.ts +26 -26
- package/dist/ts4.2/test/compressionStreamStubs.d.ts +12 -0
- package/dist/ts4.2/test/mocks.d.ts +12 -12
- package/dist/ts4.2/test/promiseState.d.ts +12 -12
- package/dist/ts4.2/test/signalToken.d.ts +26 -26
- package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -2
- package/dist/ts4.2/utils/AsyncQueue.d.ts +23 -23
- package/dist/ts4.2/utils/TypedPromise.d.ts +17 -17
- package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -13
- package/dist/ts4.2/utils/browserParser.d.ts +13 -13
- package/dist/ts4.2/utils/camelToSnakeCase.d.ts +8 -8
- package/dist/ts4.2/utils/cloneDeep.d.ts +2 -2
- package/dist/ts4.2/utils/dataPacketBuffer.d.ts +34 -34
- package/dist/ts4.2/utils/deferrable-map.d.ts +32 -32
- package/dist/ts4.2/utils/machineInspector.d.ts +54 -54
- package/dist/ts4.2/utils/serializer.d.ts +48 -48
- package/dist/ts4.2/utils/subscribeToEvents.d.ts +15 -15
- package/dist/ts4.2/utils/ttlmap.d.ts +20 -20
- package/dist/ts4.2/version.d.ts +15 -15
- package/package.json +5 -4
- package/src/api/SignalClient.e2e.test.ts +1 -3
- package/src/api/SignalClient.test.ts +160 -10
- package/src/api/SignalClient.ts +75 -54
- package/src/options.ts +16 -0
- package/src/room/PCTransport.test.ts +183 -7
- package/src/room/PCTransport.ts +197 -36
- package/src/room/PCTransportManager.ts +3 -1
- package/src/room/RTCEngine.test.ts +84 -0
- package/src/room/RTCEngine.ts +24 -5
- package/src/room/Room.test.ts +32 -6
- package/src/room/Room.ts +26 -6
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +16 -20
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +5 -5
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +60 -39
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +4 -4
- package/src/room/defaults.ts +1 -0
- package/src/room/errors.test.ts +36 -0
- package/src/room/errors.ts +27 -0
- package/src/room/events.ts +2 -1
- package/src/room/participant/publishUtils.ts +0 -19
- package/src/room/rpc/client/RpcClientManager.test.ts +51 -0
- package/src/room/rpc/client/RpcClientManager.ts +32 -15
- package/src/room/track/LocalTrack.ts +18 -7
- package/src/room/track/LocalVideoTrack.ts +10 -3
- package/src/room/track/RemoteAudioTrack.ts +3 -3
- package/src/room/track/RemoteVideoTrack.test.ts +210 -0
- package/src/room/track/RemoteVideoTrack.ts +29 -3
- package/src/room/track/utils.test.ts +201 -2
- package/src/room/track/utils.ts +88 -0
- package/src/room/utils.test.ts +124 -0
- package/src/room/utils.ts +70 -0
- package/src/test/compressionStreamStubs.ts +53 -0
|
@@ -2,11 +2,15 @@ import { type MediaDescription, parse } from 'sdp-transform';
|
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
4
|
applyVideoStartBitrate,
|
|
5
|
+
computeConnectionStartBitrate,
|
|
6
|
+
computeTrackStartBitrate,
|
|
5
7
|
conformBundledCodecFmtp,
|
|
6
8
|
ensureAudioNackAndStereo,
|
|
7
9
|
ensureVideoDDExtension,
|
|
8
10
|
extractStereoAndNackAudioFromOffer,
|
|
11
|
+
findTrackCodecPayload,
|
|
9
12
|
fmtpConfigHasParam,
|
|
13
|
+
isDuplicateAnswer,
|
|
10
14
|
placeholderMidsFromTransceivers,
|
|
11
15
|
} from './PCTransport';
|
|
12
16
|
import { ddExtensionURI } from './utils';
|
|
@@ -61,9 +65,7 @@ a=recvonly
|
|
|
61
65
|
a=rtpmap:49 H265/90000
|
|
62
66
|
a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
it('applies the bitrate only to the section whose msid track ID matches the cid', () => {
|
|
66
|
-
const { media } = parse(`v=0
|
|
68
|
+
const TWO_VIDEO_SECTIONS = `v=0
|
|
67
69
|
o=- 0 0 IN IP4 127.0.0.1
|
|
68
70
|
s=-
|
|
69
71
|
t=0 0
|
|
@@ -79,15 +81,159 @@ c=IN IP4 0.0.0.0
|
|
|
79
81
|
a=mid:1
|
|
80
82
|
a=sendonly
|
|
81
83
|
a=msid:PA_remote|camera camera-cid
|
|
82
|
-
a=rtpmap:96 VP8/90000
|
|
84
|
+
a=rtpmap:96 VP8/90000`;
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
// The same bundle after the screen share is unpublished: `unpublishTrack` sets the
|
|
87
|
+
// transceiver to `inactive`, but the section keeps its `a=msid`, so it still matches the
|
|
88
|
+
// append-only trackBitrates entry. Only the camera is still sending.
|
|
89
|
+
const UNPUBLISHED_SCREEN_SHARE = `v=0
|
|
90
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
91
|
+
s=-
|
|
92
|
+
t=0 0
|
|
93
|
+
a=group:BUNDLE 0 1
|
|
94
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
95
|
+
c=IN IP4 0.0.0.0
|
|
96
|
+
a=mid:0
|
|
97
|
+
a=inactive
|
|
98
|
+
a=msid:PA_remote|camera other-track
|
|
99
|
+
a=rtpmap:96 VP8/90000
|
|
100
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
101
|
+
c=IN IP4 0.0.0.0
|
|
102
|
+
a=mid:1
|
|
103
|
+
a=sendonly
|
|
104
|
+
a=msid:PA_remote|camera camera-cid
|
|
105
|
+
a=rtpmap:96 VP8/90000`;
|
|
106
|
+
|
|
107
|
+
// The legacy `addTrack` fallback (no `addTransceiver` support) reuses a transceiver instead
|
|
108
|
+
// of creating a sendonly one, so the published camera lands on a `sendrecv` section. Mid 1
|
|
109
|
+
// omits the direction attribute entirely, which SDP also defaults to sendrecv. Both send.
|
|
110
|
+
const LEGACY_ADD_TRACK_SECTIONS = `v=0
|
|
111
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
112
|
+
s=-
|
|
113
|
+
t=0 0
|
|
114
|
+
a=group:BUNDLE 0 1
|
|
115
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
116
|
+
c=IN IP4 0.0.0.0
|
|
117
|
+
a=mid:0
|
|
118
|
+
a=sendrecv
|
|
119
|
+
a=msid:PA_remote|camera camera-cid
|
|
120
|
+
a=rtpmap:96 VP8/90000
|
|
121
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
122
|
+
c=IN IP4 0.0.0.0
|
|
123
|
+
a=mid:1
|
|
124
|
+
a=msid:PA_remote|camera other-track
|
|
125
|
+
a=rtpmap:96 VP8/90000`;
|
|
126
|
+
|
|
127
|
+
describe('video start bitrate', () => {
|
|
128
|
+
it('matches only the section whose msid track ID matches the cid', () => {
|
|
129
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
130
|
+
|
|
131
|
+
expect(findTrackCodecPayload(media[0], 'camera-cid', 'VP8')).toBeUndefined();
|
|
132
|
+
expect(findTrackCodecPayload(media[1], 'camera-cid', 'VP8')).toBe(96);
|
|
133
|
+
// Section belongs to the track but does not offer the codec.
|
|
134
|
+
expect(findTrackCodecPayload(media[1], 'camera-cid', 'AV1')).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('applies the bitrate only to the section it is given', () => {
|
|
138
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
139
|
+
|
|
140
|
+
applyVideoStartBitrate(media[1], 96, 900);
|
|
87
141
|
|
|
88
142
|
expect(fmtpOf(media, '0', 96)).toBeUndefined();
|
|
89
143
|
expect(paramSet(fmtpOf(media, '1', 96)!)).toContain('x-google-start-bitrate=900');
|
|
90
144
|
});
|
|
145
|
+
|
|
146
|
+
it('caps camera at 1 Mbps but leaves screen share uncapped', () => {
|
|
147
|
+
const camera = { cid: 'c', codec: 'VP8', maxbr: 3_000 };
|
|
148
|
+
const screenShare = { ...camera, isScreenShare: true };
|
|
149
|
+
|
|
150
|
+
expect(computeTrackStartBitrate(camera)).toBe(1_000);
|
|
151
|
+
expect(computeTrackStartBitrate(screenShare)).toBe(2_700);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('gives no hint below the 300 kbps target floor', () => {
|
|
155
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 299 })).toBeUndefined();
|
|
156
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 })).toBe(270);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('uses one connection-level value: the largest hint across video sections', () => {
|
|
160
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
161
|
+
|
|
162
|
+
const startBitrate = computeConnectionStartBitrate(media, [
|
|
163
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
164
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
expect(startBitrate).toBe(2_700);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('ignores registered tracks with no section in the current SDP', () => {
|
|
171
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
172
|
+
|
|
173
|
+
const startBitrate = computeConnectionStartBitrate(media, [
|
|
174
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
175
|
+
// Stale entry: trackBitrates is append-only and outlives an unpublish.
|
|
176
|
+
{ cid: 'unpublished-cid', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(startBitrate).toBe(900);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('ignores a section that stopped sending but kept its msid', () => {
|
|
183
|
+
const trackBitrates = [
|
|
184
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
185
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
// While both send, the uncapped screen share wins the connection-level max.
|
|
189
|
+
expect(computeConnectionStartBitrate(parse(TWO_VIDEO_SECTIONS).media, trackBitrates)).toBe(
|
|
190
|
+
7_200,
|
|
191
|
+
);
|
|
192
|
+
// Once it is unpublished its entry is stale, so only the capped camera counts. Both
|
|
193
|
+
// directions a removed sender can land on are excluded: `inactive` from a sendonly
|
|
194
|
+
// transceiver, `recvonly` from the sendrecv one the addTrack fallback reuses.
|
|
195
|
+
expect(
|
|
196
|
+
computeConnectionStartBitrate(parse(UNPUBLISHED_SCREEN_SHARE).media, trackBitrates),
|
|
197
|
+
).toBe(900);
|
|
198
|
+
expect(
|
|
199
|
+
computeConnectionStartBitrate(
|
|
200
|
+
parse(UNPUBLISHED_SCREEN_SHARE.replace('a=inactive', 'a=recvonly')).media,
|
|
201
|
+
trackBitrates,
|
|
202
|
+
),
|
|
203
|
+
).toBe(900);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('gives no connection value when no section maps to a published track', () => {
|
|
207
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
208
|
+
|
|
209
|
+
expect(computeConnectionStartBitrate(media, [])).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('counts sendrecv and direction-less sections, which still send local media', () => {
|
|
213
|
+
// The legacy addTrack fallback never produces `sendonly`, so a strict match on it would
|
|
214
|
+
// leave those clients with no hint at all.
|
|
215
|
+
const { media } = parse(LEGACY_ADD_TRACK_SECTIONS);
|
|
216
|
+
|
|
217
|
+
expect(
|
|
218
|
+
computeConnectionStartBitrate(media, [{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 }]),
|
|
219
|
+
).toBe(900);
|
|
220
|
+
expect(
|
|
221
|
+
computeConnectionStartBitrate(media, [
|
|
222
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 2_000, isScreenShare: true },
|
|
223
|
+
]),
|
|
224
|
+
).toBe(1_800);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('leaves the hint unset when only non-sending sections match', () => {
|
|
228
|
+
// Nothing is published, so the one-shot hint must not be consumed on a dead section.
|
|
229
|
+
const { media } = parse(UNPUBLISHED_SCREEN_SHARE);
|
|
230
|
+
|
|
231
|
+
expect(
|
|
232
|
+
computeConnectionStartBitrate(media, [
|
|
233
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
234
|
+
]),
|
|
235
|
+
).toBeUndefined();
|
|
236
|
+
});
|
|
91
237
|
});
|
|
92
238
|
|
|
93
239
|
describe('placeholderMidsFromTransceivers', () => {
|
|
@@ -390,3 +536,33 @@ a=extmap:3 ${ddExtensionURI}`);
|
|
|
390
536
|
expect(ddOf(sdp.media, '1')).toBe(12);
|
|
391
537
|
});
|
|
392
538
|
});
|
|
539
|
+
|
|
540
|
+
describe('isDuplicateAnswer', () => {
|
|
541
|
+
it('drops an answer while the same offer is still being answered', () => {
|
|
542
|
+
expect(isDuplicateAnswer(3, 3, 0, 'have-local-offer', false)).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('drops a repeat of an unnumbered answer while the first is still applying', () => {
|
|
546
|
+
expect(isDuplicateAnswer(0, 0, 0, 'have-local-offer', false)).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('drops an answer for an offer whose answer was already applied', () => {
|
|
550
|
+
expect(isDuplicateAnswer(3, undefined, 3, 'stable', false)).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('accepts a retry after a failed apply: nothing in flight, nothing applied', () => {
|
|
554
|
+
expect(isDuplicateAnswer(3, undefined, 2, 'have-local-offer', false)).toBe(false);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('accepts the answer for a new offer', () => {
|
|
558
|
+
expect(isDuplicateAnswer(4, undefined, 3, 'have-local-offer', false)).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('without an offerId, drops an answer in stable', () => {
|
|
562
|
+
expect(isDuplicateAnswer(0, undefined, 0, 'stable', false)).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('without an offerId, accepts the answer to a deferred initial offer', () => {
|
|
566
|
+
expect(isDuplicateAnswer(0, undefined, 0, 'stable', true)).toBe(false);
|
|
567
|
+
});
|
|
568
|
+
});
|
package/src/room/PCTransport.ts
CHANGED
|
@@ -34,51 +34,130 @@ const startBitrateMultiplier = 0.9;
|
|
|
34
34
|
/** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
|
|
35
35
|
const maxStartBitrateKbps = 1000;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Minimum target bitrate in kbps for the start bitrate hint. Below this, seeding above the
|
|
39
|
+
* real capacity costs more than the ramp it saves, so libwebrtc's default is left in place.
|
|
40
|
+
*/
|
|
41
|
+
const minTargetBitrateKbps = 300;
|
|
42
|
+
|
|
37
43
|
const debounceInterval = 20;
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
|
-
*
|
|
41
|
-
* This SDP munging is used for a bitrate setting that cannot be applied through
|
|
42
|
-
* `RTCRtpEncodingParameters`.
|
|
46
|
+
* Codec payload for `codec` in this media section, when the section carries `cid`.
|
|
43
47
|
*
|
|
44
|
-
* Returns `undefined` when the section does not belong to the track, `0` when
|
|
45
|
-
*
|
|
46
|
-
* requested codec is present (whether the bitrate was added or already set).
|
|
48
|
+
* Returns `undefined` when the section does not belong to the track, `0` when it does but
|
|
49
|
+
* does not offer the requested codec, and the codec payload otherwise.
|
|
47
50
|
*
|
|
48
51
|
* @internal
|
|
49
52
|
*/
|
|
50
|
-
export function
|
|
53
|
+
export function findTrackCodecPayload(
|
|
51
54
|
media: MediaDescription,
|
|
52
55
|
cid: string,
|
|
53
56
|
codec: string,
|
|
54
|
-
maxbr: number,
|
|
55
|
-
isScreenShare = false,
|
|
56
57
|
): number | undefined {
|
|
57
58
|
if (!media.msid?.includes(cid)) {
|
|
58
59
|
return undefined;
|
|
59
60
|
}
|
|
61
|
+
return media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
|
|
62
|
+
}
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Start bitrate hinted for a single track, or `undefined` when its target is too low to
|
|
66
|
+
* be worth seeding.
|
|
67
|
+
*
|
|
68
|
+
* 90% of the target leaves ~10% headroom for the estimator to settle. The same multiplier
|
|
69
|
+
* is used for every codec because the target already reflects the codec's efficiency.
|
|
70
|
+
* Camera is capped at 1 Mbps so the estimator does not open too aggressively on a
|
|
71
|
+
* high-bitrate track; screen share is exempt, because its content needs the bitrate
|
|
72
|
+
* immediately to stay legible.
|
|
73
|
+
*
|
|
74
|
+
* TODO: adjust dynamically from network conditions (e.g. a previous BWE estimate) rather
|
|
75
|
+
* than a fixed cap.
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
export function computeTrackStartBitrate(trackbr: TrackBitrateInfo): number | undefined {
|
|
80
|
+
if (trackbr.maxbr < minTargetBitrateKbps) {
|
|
81
|
+
return undefined;
|
|
65
82
|
}
|
|
83
|
+
const calculated = Math.round(trackbr.maxbr * startBitrateMultiplier);
|
|
84
|
+
return trackbr.isScreenShare ? calculated : Math.min(calculated, maxStartBitrateKbps);
|
|
85
|
+
}
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The single start bitrate for this peer connection: the largest hint among the video
|
|
89
|
+
* m-sections of `media` that map to a published track.
|
|
90
|
+
*
|
|
91
|
+
* libwebrtc reads `x-google-start-bitrate` per m-section but applies it to the shared
|
|
92
|
+
* `Call` (`WebRtcVideoSendChannel::ApplyChangedParams` -> `SetSdpBitrateParameters`), where
|
|
93
|
+
* `RtpBitrateConfigurator` holds one config for the whole connection. Differing per-section
|
|
94
|
+
* values are therefore last-writer-wins, decided by m-section order, so every video section
|
|
95
|
+
* gets the same number instead.
|
|
96
|
+
*
|
|
97
|
+
* Only sections that can send local media are considered. `trackBitrates` is append-only and an
|
|
98
|
+
* unpublished section keeps its `a=msid`, so matching on msid alone would still pair a stale
|
|
99
|
+
* entry with its old section — letting an uncapped screen-share target seed a connection that
|
|
100
|
+
* now carries only a camera, or consuming the one-shot hint on a section that sends nothing.
|
|
101
|
+
*
|
|
102
|
+
* The direction is the discriminator, as an exclusion rather than a match: `recvonly` and
|
|
103
|
+
* `inactive` are the only directions that cannot carry local media, and they are exactly the
|
|
104
|
+
* two a dead section lands on — unpublish sets `inactive` (explicitly in
|
|
105
|
+
* `LocalParticipant.unpublishTrack`, and via `removeTrack`'s sendonly -> inactive transition for
|
|
106
|
+
* the simulcast senders), `removeTrack` on a `sendrecv` transceiver leaves `recvonly`, and the
|
|
107
|
+
* pre-populated placeholders are `recvonly`. Everything else sends: `sendonly` from the
|
|
108
|
+
* `addTransceiver` path, `sendrecv` from the legacy `addTrack` fallback (which reuses a
|
|
109
|
+
* transceiver rather than creating a sendonly one), and a section with no direction attribute,
|
|
110
|
+
* which SDP defaults to `sendrecv`.
|
|
111
|
+
*
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
export function computeConnectionStartBitrate(
|
|
115
|
+
media: MediaDescription[],
|
|
116
|
+
trackBitrates: TrackBitrateInfo[],
|
|
117
|
+
): number | undefined {
|
|
118
|
+
let connectionStartBitrate: number | undefined;
|
|
119
|
+
for (const m of media) {
|
|
120
|
+
if (m.type !== 'video' || m.direction === 'recvonly' || m.direction === 'inactive') {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const trackbr of trackBitrates) {
|
|
124
|
+
if (!trackbr.cid) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const codecPayload = findTrackCodecPayload(m, trackbr.cid, trackbr.codec);
|
|
128
|
+
if (codecPayload === undefined) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr) : undefined;
|
|
132
|
+
if (
|
|
133
|
+
startBitrate !== undefined &&
|
|
134
|
+
(connectionStartBitrate === undefined || startBitrate > connectionStartBitrate)
|
|
135
|
+
) {
|
|
136
|
+
connectionStartBitrate = startBitrate;
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return connectionStartBitrate;
|
|
142
|
+
}
|
|
75
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Declares `x-google-start-bitrate` on `codecPayload`'s fmtp. This SDP munging is used for
|
|
146
|
+
* a bitrate setting that cannot be applied through `RTCRtpEncodingParameters`.
|
|
147
|
+
*
|
|
148
|
+
* Returns whether the section now carries the hint.
|
|
149
|
+
*
|
|
150
|
+
* @internal
|
|
151
|
+
*/
|
|
152
|
+
export function applyVideoStartBitrate(
|
|
153
|
+
media: MediaDescription,
|
|
154
|
+
codecPayload: number,
|
|
155
|
+
startBitrate: number,
|
|
156
|
+
): boolean {
|
|
76
157
|
const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
|
|
77
158
|
if (fmtp) {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// This forces every track sharing that payload to use the initial track's
|
|
81
|
-
// start bitrate.
|
|
159
|
+
// A payload type is shared across the bundle, so a value written for one section is
|
|
160
|
+
// already the connection-level one; leave it rather than rewrite it.
|
|
82
161
|
if (!fmtp.config.includes('x-google-start-bitrate')) {
|
|
83
162
|
fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
|
|
84
163
|
}
|
|
@@ -90,7 +169,7 @@ export function applyVideoStartBitrate(
|
|
|
90
169
|
});
|
|
91
170
|
}
|
|
92
171
|
|
|
93
|
-
return
|
|
172
|
+
return true;
|
|
94
173
|
}
|
|
95
174
|
|
|
96
175
|
export const PCEvents = {
|
|
@@ -127,6 +206,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
127
206
|
|
|
128
207
|
latestOfferId: number = 0;
|
|
129
208
|
|
|
209
|
+
private answeredOfferId: number = 0;
|
|
210
|
+
|
|
211
|
+
/** offerId (0 if unnumbered) of the answer being applied right now */
|
|
212
|
+
private answerInFlight: number | undefined;
|
|
213
|
+
|
|
130
214
|
latestAcknowledgedOfferId: number = 0;
|
|
131
215
|
|
|
132
216
|
private offerLock: Mutex;
|
|
@@ -141,6 +225,15 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
141
225
|
|
|
142
226
|
trackBitrates: TrackBitrateInfo[] = [];
|
|
143
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Whether an offer carrying the connection-level `x-google-start-bitrate` has been
|
|
230
|
+
* accepted locally. The hint is written once per peer connection: libwebrtc retains
|
|
231
|
+
* `start_bitrate_bps` in `RtpBitrateConfigurator` and re-applies it on network route
|
|
232
|
+
* changes, so a later rewrite is at best a no-op and at worst restarts a converged
|
|
233
|
+
* bandwidth estimator. A new peer connection (full reconnect) seeds a new estimator.
|
|
234
|
+
*/
|
|
235
|
+
private hasAppliedVideoStartBitrate = false;
|
|
236
|
+
|
|
144
237
|
remoteStereoMids: string[] = [];
|
|
145
238
|
|
|
146
239
|
remoteNackMids: string[] = [];
|
|
@@ -250,6 +343,36 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
250
343
|
});
|
|
251
344
|
return false;
|
|
252
345
|
}
|
|
346
|
+
if (sd.type === 'answer') {
|
|
347
|
+
if (
|
|
348
|
+
isDuplicateAnswer(
|
|
349
|
+
offerId,
|
|
350
|
+
this.answerInFlight,
|
|
351
|
+
this.answeredOfferId,
|
|
352
|
+
this._pc?.signalingState,
|
|
353
|
+
this.pendingInitialOffer !== undefined,
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
// a repeat of an answer already applied or being applied (e.g.
|
|
357
|
+
// duplicated by a proxy): there is no local offer left to answer
|
|
358
|
+
this.log.warn('ignoring duplicate answer', { offerId });
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
// claimed before any await, so a copy arriving mid-apply is caught too;
|
|
362
|
+
// released however the apply ends, so a genuine retry is still possible
|
|
363
|
+
this.answerInFlight = offerId;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return await this.applyRemoteDescription(sd, offerId);
|
|
367
|
+
} finally {
|
|
368
|
+
if (sd.type === 'answer') this.answerInFlight = undefined;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async applyRemoteDescription(
|
|
373
|
+
sd: RTCSessionDescriptionInit,
|
|
374
|
+
offerId: number,
|
|
375
|
+
): Promise<boolean> {
|
|
253
376
|
let mungedSDP: string | undefined = undefined;
|
|
254
377
|
if (sd.type === 'offer') {
|
|
255
378
|
let { stereoMids, nackMids } = extractStereoAndNackAudioFromOffer(sd);
|
|
@@ -331,6 +454,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
331
454
|
mungedSDP = write(sdpParsed);
|
|
332
455
|
}
|
|
333
456
|
await this.setMungedSDP(sd, mungedSDP, true);
|
|
457
|
+
// only a successfully applied answer makes later copies duplicates
|
|
458
|
+
if (sd.type === 'answer' && offerId > 0) this.answeredOfferId = offerId;
|
|
334
459
|
|
|
335
460
|
if (this.pendingCandidates.length > 0) {
|
|
336
461
|
this.iceLog.debug('flushing queued ICE candidates', {
|
|
@@ -453,6 +578,14 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
453
578
|
this.log.debug('original offer', { sdp: offer.sdp });
|
|
454
579
|
|
|
455
580
|
const sdpParsed = parse(offer.sdp ?? '');
|
|
581
|
+
// One value for every video m-section, written only on the first offer that carries
|
|
582
|
+
// local video: the hint is connection-level in libwebrtc, so differing per-section
|
|
583
|
+
// values would be last-writer-wins on m-section order. Offers before any video is
|
|
584
|
+
// published (data channel or audio only) find no target and leave the latch unset.
|
|
585
|
+
const connectionStartBitrate = this.hasAppliedVideoStartBitrate
|
|
586
|
+
? undefined
|
|
587
|
+
: computeConnectionStartBitrate(sdpParsed.media, this.trackBitrates);
|
|
588
|
+
let appliedVideoStartBitrate = false;
|
|
456
589
|
sdpParsed.media.forEach((media) => {
|
|
457
590
|
ensureIPAddrMatchVersion(media);
|
|
458
591
|
if (media.type === 'audio') {
|
|
@@ -463,19 +596,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
463
596
|
return false;
|
|
464
597
|
}
|
|
465
598
|
|
|
466
|
-
const codecPayload =
|
|
467
|
-
media,
|
|
468
|
-
trackbr.cid,
|
|
469
|
-
trackbr.codec,
|
|
470
|
-
trackbr.maxbr,
|
|
471
|
-
trackbr.isScreenShare,
|
|
472
|
-
);
|
|
599
|
+
const codecPayload = findTrackCodecPayload(media, trackbr.cid, trackbr.codec);
|
|
473
600
|
if (codecPayload === undefined) {
|
|
474
601
|
return false;
|
|
475
602
|
}
|
|
476
603
|
|
|
477
|
-
if (codecPayload > 0
|
|
478
|
-
|
|
604
|
+
if (codecPayload > 0) {
|
|
605
|
+
if (connectionStartBitrate !== undefined) {
|
|
606
|
+
appliedVideoStartBitrate =
|
|
607
|
+
applyVideoStartBitrate(media, codecPayload, connectionStartBitrate) ||
|
|
608
|
+
appliedVideoStartBitrate;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (isSVCCodec(trackbr.codec) && !isSafari()) {
|
|
612
|
+
this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
|
|
613
|
+
}
|
|
479
614
|
}
|
|
480
615
|
|
|
481
616
|
return true;
|
|
@@ -500,7 +635,13 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
500
635
|
});
|
|
501
636
|
return;
|
|
502
637
|
}
|
|
503
|
-
|
|
638
|
+
const mungedSdp = write(sdpParsed);
|
|
639
|
+
await this.setMungedSDP(offer, mungedSdp);
|
|
640
|
+
// setMungedSDP falls back to the unmunged SDP on rejection. Only consume the
|
|
641
|
+
// one-shot hint once the SDP carrying it has been accepted locally.
|
|
642
|
+
if (appliedVideoStartBitrate && offer.sdp === mungedSdp) {
|
|
643
|
+
this.hasAppliedVideoStartBitrate = true;
|
|
644
|
+
}
|
|
504
645
|
this.onOffer(offer, this.latestOfferId);
|
|
505
646
|
} finally {
|
|
506
647
|
unlock();
|
|
@@ -952,6 +1093,26 @@ export function conformBundledCodecFmtp(
|
|
|
952
1093
|
}
|
|
953
1094
|
|
|
954
1095
|
/** @internal */
|
|
1096
|
+
/**
|
|
1097
|
+
* Whether an incoming answer repeats one already applied or being applied.
|
|
1098
|
+
* Any answer for the offer whose answer is in flight is a duplicate, numbered
|
|
1099
|
+
* or not. With an offerId (current servers) an answer is also a duplicate if
|
|
1100
|
+
* that offer's answer was already applied. Without one, fall back to the
|
|
1101
|
+
* signaling state: in `stable` no local offer is pending, unless the initial
|
|
1102
|
+
* offer is still deferred (it is set locally only once its answer arrives).
|
|
1103
|
+
*/
|
|
1104
|
+
export function isDuplicateAnswer(
|
|
1105
|
+
offerId: number,
|
|
1106
|
+
answerInFlight: number | undefined,
|
|
1107
|
+
answeredOfferId: number,
|
|
1108
|
+
signalingState: RTCSignalingState | undefined,
|
|
1109
|
+
hasPendingInitialOffer: boolean,
|
|
1110
|
+
): boolean {
|
|
1111
|
+
if (answerInFlight === offerId) return true;
|
|
1112
|
+
if (offerId > 0) return offerId === answeredOfferId;
|
|
1113
|
+
return signalingState === 'stable' && !hasPendingInitialOffer;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
955
1116
|
export function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
|
|
956
1117
|
stereoMids: string[];
|
|
957
1118
|
nackMids: string[];
|
|
@@ -229,7 +229,9 @@ export class PCTransportManager {
|
|
|
229
229
|
this.publisher.getConnectionState() !== 'connecting'
|
|
230
230
|
) {
|
|
231
231
|
this.log.debug('negotiation required, start negotiating');
|
|
232
|
-
this.publisher.negotiate()
|
|
232
|
+
this.publisher.negotiate().catch((err) => {
|
|
233
|
+
this.log.error(err);
|
|
234
|
+
});
|
|
233
235
|
}
|
|
234
236
|
await Promise.all(
|
|
235
237
|
this.requiredTransports?.map((transport) =>
|
|
@@ -10,6 +10,7 @@ import { PCTransportState } from './PCTransportManager';
|
|
|
10
10
|
import RTCEngine, { DataChannelKind } from './RTCEngine';
|
|
11
11
|
import { roomOptionDefaults } from './defaults';
|
|
12
12
|
import { PublishDataError, UnexpectedConnectionState } from './errors';
|
|
13
|
+
import { EngineEvent } from './events';
|
|
13
14
|
|
|
14
15
|
describe('RTCEngine', () => {
|
|
15
16
|
const originalRTCRtpSender = window.RTCRtpSender;
|
|
@@ -919,4 +920,87 @@ describe('RTCEngine', () => {
|
|
|
919
920
|
expect(handleDisconnect).not.toHaveBeenCalled();
|
|
920
921
|
});
|
|
921
922
|
});
|
|
923
|
+
|
|
924
|
+
describe('negotiate', () => {
|
|
925
|
+
/**
|
|
926
|
+
* An engine whose `pcManager` parks in `negotiate()` until its abort controller fires, so
|
|
927
|
+
* several negotiations can be held in flight at once. The controllers are captured so tests
|
|
928
|
+
* can check the abort actually reached each in-flight call.
|
|
929
|
+
*/
|
|
930
|
+
function stubNegotiableEngine(engine: RTCEngine) {
|
|
931
|
+
const controllers: AbortController[] = [];
|
|
932
|
+
const negotiate = vi.fn((abortController: AbortController) => {
|
|
933
|
+
controllers.push(abortController);
|
|
934
|
+
return new Promise<void>((_resolve, reject) => {
|
|
935
|
+
abortController.signal.addEventListener(
|
|
936
|
+
'abort',
|
|
937
|
+
() => reject(new Error('negotiation aborted')),
|
|
938
|
+
{ once: true },
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
943
|
+
_isClosed: false,
|
|
944
|
+
pcManager: {
|
|
945
|
+
requirePublisher: vi.fn(),
|
|
946
|
+
// a non-empty transceiver list keeps negotiate() off the createDataChannels path
|
|
947
|
+
publisher: { getTransceivers: () => [{}], off: vi.fn(), once: vi.fn() },
|
|
948
|
+
negotiate,
|
|
949
|
+
},
|
|
950
|
+
handleDisconnect: vi.fn(),
|
|
951
|
+
});
|
|
952
|
+
return { negotiate, controllers };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const pendingAborts = (engine: RTCEngine) =>
|
|
956
|
+
(engine as unknown as { pendingNegotiationAborts: Set<() => void> }).pendingNegotiationAborts;
|
|
957
|
+
|
|
958
|
+
it('does not add a Closing/Restarting listener per in-flight negotiate call', async () => {
|
|
959
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
960
|
+
const { negotiate, controllers } = stubNegotiableEngine(engine);
|
|
961
|
+
|
|
962
|
+
// A burst of server-initiated renegotiations used to stack a listener pair per call, which
|
|
963
|
+
// trips the emitter's max-listener warning at 11.
|
|
964
|
+
const closingBaseline = engine.listenerCount(EngineEvent.Closing);
|
|
965
|
+
const restartingBaseline = engine.listenerCount(EngineEvent.Restarting);
|
|
966
|
+
|
|
967
|
+
const pending = Array.from({ length: 20 }, () => engine.negotiate());
|
|
968
|
+
await tick();
|
|
969
|
+
|
|
970
|
+
expect(negotiate).toHaveBeenCalledTimes(20);
|
|
971
|
+
expect(engine.listenerCount(EngineEvent.Closing)).toBe(closingBaseline);
|
|
972
|
+
expect(engine.listenerCount(EngineEvent.Restarting)).toBe(restartingBaseline);
|
|
973
|
+
|
|
974
|
+
// The single central listener still has to reach every one of them.
|
|
975
|
+
engine.emit(EngineEvent.Closing);
|
|
976
|
+
await expect(Promise.all(pending)).resolves.toHaveLength(20);
|
|
977
|
+
expect(controllers).toHaveLength(20);
|
|
978
|
+
expect(controllers.every((c) => c.signal.aborted)).toBe(true);
|
|
979
|
+
expect(pendingAborts(engine).size).toBe(0);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('aborts in-flight negotiations on restart without disarming later ones', async () => {
|
|
983
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
984
|
+
const { controllers } = stubNegotiableEngine(engine);
|
|
985
|
+
|
|
986
|
+
const firstBatch = [engine.negotiate(), engine.negotiate()];
|
|
987
|
+
await tick();
|
|
988
|
+
engine.emit(EngineEvent.Restarting);
|
|
989
|
+
await expect(Promise.all(firstBatch)).resolves.toHaveLength(2);
|
|
990
|
+
expect(controllers.every((c) => c.signal.aborted)).toBe(true);
|
|
991
|
+
|
|
992
|
+
// Restarting fires on every reconnect attempt, so the fan-out point has to re-arm: a
|
|
993
|
+
// negotiation started after a restart must not be aborted on arrival.
|
|
994
|
+
const afterRestart = engine.negotiate();
|
|
995
|
+
await tick();
|
|
996
|
+
expect(controllers).toHaveLength(3);
|
|
997
|
+
expect(controllers[2].signal.aborted).toBe(false);
|
|
998
|
+
expect(pendingAborts(engine).size).toBe(1);
|
|
999
|
+
|
|
1000
|
+
engine.emit(EngineEvent.Restarting);
|
|
1001
|
+
await expect(afterRestart).resolves.toBeUndefined();
|
|
1002
|
+
expect(controllers[2].signal.aborted).toBe(true);
|
|
1003
|
+
expect(pendingAborts(engine).size).toBe(0);
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
922
1006
|
});
|