arnacon-webrtc-service 0.2.1 → 0.2.3
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/package.json
CHANGED
|
@@ -6,6 +6,8 @@ const {
|
|
|
6
6
|
transportOpenA,
|
|
7
7
|
transportOpenB,
|
|
8
8
|
aOffers,
|
|
9
|
+
bOffers,
|
|
10
|
+
aAnswers,
|
|
9
11
|
bAnswers,
|
|
10
12
|
bRejects,
|
|
11
13
|
aEnds,
|
|
@@ -92,3 +94,81 @@ test("B ends -> clean teardown -> both reusable", async () => {
|
|
|
92
94
|
await aCompletesEnd(ctx, "end-answer-a");
|
|
93
95
|
expectEnded(ctx);
|
|
94
96
|
});
|
|
97
|
+
|
|
98
|
+
test("long reuse soak: repeated decline/end cycles stay reusable and reconnect cleanly", async () => {
|
|
99
|
+
const ctx = buildWebRtcScenario();
|
|
100
|
+
await transportOpenA(ctx);
|
|
101
|
+
await transportOpenB(ctx);
|
|
102
|
+
|
|
103
|
+
const rounds = 8;
|
|
104
|
+
for (let i = 1; i <= rounds; i += 1) {
|
|
105
|
+
await aOffers(ctx, `offer-${i}`);
|
|
106
|
+
if (i % 2 === 0) {
|
|
107
|
+
await bRejects(ctx);
|
|
108
|
+
expectLegStates(ctx, S.ENDING, S.REJECTED);
|
|
109
|
+
await aCompletesEnd(ctx, `end-answer-${i}`);
|
|
110
|
+
} else {
|
|
111
|
+
await bAnswers(ctx, `answer-${i}`);
|
|
112
|
+
expectInCall(ctx);
|
|
113
|
+
await aEnds(ctx);
|
|
114
|
+
expectLegStates(ctx, S.CONNECTED, S.ENDING);
|
|
115
|
+
await bCompletesEnd(ctx, `end-answer-${i}`);
|
|
116
|
+
}
|
|
117
|
+
expectReusable(ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await aOffers(ctx, "final-offer");
|
|
121
|
+
await bAnswers(ctx, "final-answer");
|
|
122
|
+
expectInCall(ctx);
|
|
123
|
+
// Odd rounds establish media before ending (4 times for rounds=8), then one
|
|
124
|
+
// final successful call at the end => 5 total connect operations.
|
|
125
|
+
const expectedConnects = Math.ceil(rounds / 2) + 1;
|
|
126
|
+
expectMediaDisconnected(ctx, Math.ceil(rounds / 2));
|
|
127
|
+
if (ctx.media.connects.length !== expectedConnects) {
|
|
128
|
+
throw new Error(`expected ${expectedConnects} media connects, got ${ctx.media.connects.length}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("glare after reuse converges and still tears down cleanly", async () => {
|
|
133
|
+
const ctx = buildWebRtcScenario();
|
|
134
|
+
await transportOpenA(ctx);
|
|
135
|
+
await transportOpenB(ctx);
|
|
136
|
+
|
|
137
|
+
await aOffers(ctx, "offer-1");
|
|
138
|
+
await bRejects(ctx);
|
|
139
|
+
await aCompletesEnd(ctx, "end-answer-1");
|
|
140
|
+
expectReusable(ctx);
|
|
141
|
+
|
|
142
|
+
await Promise.all([
|
|
143
|
+
aOffers(ctx, "offer-glare-a"),
|
|
144
|
+
bOffers(ctx, "offer-glare-b"),
|
|
145
|
+
]);
|
|
146
|
+
await aAnswers(ctx, "answer-glare-a");
|
|
147
|
+
await bAnswers(ctx, "answer-glare-b");
|
|
148
|
+
expectInCall(ctx);
|
|
149
|
+
|
|
150
|
+
await bEnds(ctx);
|
|
151
|
+
await aCompletesEnd(ctx, "end-answer-glare");
|
|
152
|
+
expectEnded(ctx);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("double-end race then immediate redial does not leak teardown state", async () => {
|
|
156
|
+
const ctx = buildWebRtcScenario();
|
|
157
|
+
await transportOpenA(ctx);
|
|
158
|
+
await transportOpenB(ctx);
|
|
159
|
+
await aOffers(ctx, "offer-1");
|
|
160
|
+
await bAnswers(ctx, "answer-1");
|
|
161
|
+
expectInCall(ctx);
|
|
162
|
+
|
|
163
|
+
await Promise.all([aEnds(ctx), bEnds(ctx)]);
|
|
164
|
+
await Promise.all([
|
|
165
|
+
aCompletesEnd(ctx, "end-answer-a"),
|
|
166
|
+
bCompletesEnd(ctx, "end-answer-b"),
|
|
167
|
+
]);
|
|
168
|
+
expectReusable(ctx);
|
|
169
|
+
expectMediaDisconnected(ctx, 1);
|
|
170
|
+
|
|
171
|
+
await aOffers(ctx, "offer-2");
|
|
172
|
+
await bAnswers(ctx, "answer-2");
|
|
173
|
+
expectInCall(ctx);
|
|
174
|
+
});
|
|
@@ -118,6 +118,122 @@ class MidLifecyclePeerConnection extends FakePeerConnection {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
class DuplicateTrackOnRecoveryPeerConnection extends FakePeerConnection {
|
|
122
|
+
constructor({ audioMid = "2", ...opts } = {}) {
|
|
123
|
+
super(opts);
|
|
124
|
+
this.transceivers = [
|
|
125
|
+
{ kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {} } },
|
|
126
|
+
{ kind: "audio", mid: audioMid, setDirection() {}, sender: { replaceTrack: async () => {}, track: null } },
|
|
127
|
+
];
|
|
128
|
+
this._remoteOfferAudioMid = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_extractAudioMid(sdp = "") {
|
|
132
|
+
const sections = String(sdp).split(/\r?\nm=/);
|
|
133
|
+
for (const rawSection of sections) {
|
|
134
|
+
const section = rawSection.startsWith("m=") ? rawSection : `m=${rawSection}`;
|
|
135
|
+
if (!/^m=audio\b/m.test(section)) continue;
|
|
136
|
+
const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1];
|
|
137
|
+
if (mid) return String(mid);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_hasAudioMid(mid) {
|
|
143
|
+
return this.transceivers.some((t) => t.kind === "audio" && String(t.mid || "") === String(mid || ""));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
addTrack(track) {
|
|
147
|
+
const audio = this.transceivers.find((t) => t.kind === "audio");
|
|
148
|
+
if (audio?.sender?.track === track) {
|
|
149
|
+
throw new Error("Track already added");
|
|
150
|
+
}
|
|
151
|
+
if (audio?.sender) audio.sender.track = track;
|
|
152
|
+
return { kind: track?.kind || "audio" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async setRemoteDescription(d) {
|
|
156
|
+
if (d?.type === "offer") {
|
|
157
|
+
this._remoteOfferAudioMid = this._extractAudioMid(d?.sdp || "");
|
|
158
|
+
if (this._remoteOfferAudioMid && !this._hasAudioMid(this._remoteOfferAudioMid)) {
|
|
159
|
+
throw new Error(`Transceiver with mid=${this._remoteOfferAudioMid} not found`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.remoteDescription = d;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class MidMapLagPeerConnection extends FakePeerConnection {
|
|
167
|
+
constructor({ initialAudioMid = "1", ...opts } = {}) {
|
|
168
|
+
super(opts);
|
|
169
|
+
this.transceivers = [
|
|
170
|
+
{ kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {} } },
|
|
171
|
+
{ kind: "audio", mid: initialAudioMid, setDirection() {}, sender: { replaceTrack: async () => {}, track: null } },
|
|
172
|
+
];
|
|
173
|
+
this.acceptedAudioMids = new Set([String(initialAudioMid)]);
|
|
174
|
+
this._remoteOfferAudioMid = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_extractAudioMid(sdp = "") {
|
|
178
|
+
const sections = String(sdp).split(/\r?\nm=/);
|
|
179
|
+
for (const rawSection of sections) {
|
|
180
|
+
const section = rawSection.startsWith("m=") ? rawSection : `m=${rawSection}`;
|
|
181
|
+
if (!/^m=audio\b/m.test(section)) continue;
|
|
182
|
+
const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1];
|
|
183
|
+
if (mid) return String(mid).trim();
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
addTrack(track) {
|
|
189
|
+
const audio = this.transceivers.find((t) => t.kind === "audio");
|
|
190
|
+
if (audio?.sender) audio.sender.track = track;
|
|
191
|
+
return { kind: track?.kind || "audio" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
addTransceiver(kindOrTrack) {
|
|
195
|
+
const isAudio = kindOrTrack === "audio" || kindOrTrack?.kind === "audio";
|
|
196
|
+
if (!isAudio) return null;
|
|
197
|
+
const existingNumericMids = this.transceivers
|
|
198
|
+
.map((t) => Number.parseInt(String(t.mid ?? ""), 10))
|
|
199
|
+
.filter((n) => Number.isFinite(n));
|
|
200
|
+
const nextMid = String(existingNumericMids.length ? Math.max(...existingNumericMids) + 1 : 1);
|
|
201
|
+
const created = {
|
|
202
|
+
kind: "audio",
|
|
203
|
+
setDirection() {},
|
|
204
|
+
sender: { replaceTrack: async () => {}, track: kindOrTrack?.kind === "audio" ? kindOrTrack : null },
|
|
205
|
+
};
|
|
206
|
+
let currentMid = nextMid;
|
|
207
|
+
Object.defineProperty(created, "mid", {
|
|
208
|
+
get() {
|
|
209
|
+
return currentMid;
|
|
210
|
+
},
|
|
211
|
+
set: (newMid) => {
|
|
212
|
+
const normalized = String(newMid ?? "").trim();
|
|
213
|
+
if (normalized === currentMid) return;
|
|
214
|
+
this.acceptedAudioMids.delete(currentMid);
|
|
215
|
+
currentMid = normalized;
|
|
216
|
+
if (currentMid) this.acceptedAudioMids.add(currentMid);
|
|
217
|
+
},
|
|
218
|
+
enumerable: true,
|
|
219
|
+
configurable: true,
|
|
220
|
+
});
|
|
221
|
+
this.transceivers.push(created);
|
|
222
|
+
this.acceptedAudioMids.add(nextMid);
|
|
223
|
+
return created;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async setRemoteDescription(d) {
|
|
227
|
+
if (d?.type === "offer") {
|
|
228
|
+
this._remoteOfferAudioMid = this._extractAudioMid(d?.sdp || "");
|
|
229
|
+
if (this._remoteOfferAudioMid && !this.acceptedAudioMids.has(this._remoteOfferAudioMid)) {
|
|
230
|
+
throw new Error(`Transceiver with mid=${this._remoteOfferAudioMid} not found`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
this.remoteDescription = d;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
121
237
|
function deepLifecyclePrimitives() {
|
|
122
238
|
const base = fakePrimitives();
|
|
123
239
|
const callSdpUseCases = new CallSdpUseCases({
|
|
@@ -474,3 +590,241 @@ test("DEEP BUG REPRO: stale audio MID can fail during setLocalDescription", asyn
|
|
|
474
590
|
"redial offer should not wedge even when MID mismatch surfaces at setLocalDescription time",
|
|
475
591
|
);
|
|
476
592
|
});
|
|
593
|
+
|
|
594
|
+
test("DEEP BUG REPRO: recovery must not throw when local track is already bound", async () => {
|
|
595
|
+
const signaling = new FakeSignaling();
|
|
596
|
+
const localTrack = { kind: "audio" };
|
|
597
|
+
const pc = new DuplicateTrackOnRecoveryPeerConnection({
|
|
598
|
+
audioMid: "2",
|
|
599
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
600
|
+
});
|
|
601
|
+
pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
|
|
602
|
+
const session = {
|
|
603
|
+
sessionId: "alice|bob",
|
|
604
|
+
callerEns: "alice.secnum.global",
|
|
605
|
+
toIdentity: "bob.secnum.global",
|
|
606
|
+
peerConnection: pc,
|
|
607
|
+
localAudioTrack: localTrack,
|
|
608
|
+
};
|
|
609
|
+
const neg = new WebRtcNegotiation({
|
|
610
|
+
id: "alice",
|
|
611
|
+
endpoint: "alice.secnum.global",
|
|
612
|
+
session,
|
|
613
|
+
signaling,
|
|
614
|
+
primitives: deepLifecyclePrimitives(),
|
|
615
|
+
logger: silentLogger,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const redialOfferWithAudioMid =
|
|
619
|
+
"v=0\r\n" +
|
|
620
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
621
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
622
|
+
"a=mid:0\r\n" +
|
|
623
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
624
|
+
"a=mid:1\r\n" +
|
|
625
|
+
"a=sendrecv\r\n";
|
|
626
|
+
|
|
627
|
+
await assert.doesNotReject(
|
|
628
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
629
|
+
"recovery should tolerate already-bound tracks and continue",
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("DEEP BUG REPRO: mid=2 map-lag recovers via forced transceiver create", async () => {
|
|
634
|
+
const signaling = new FakeSignaling();
|
|
635
|
+
const localTrack = { kind: "audio" };
|
|
636
|
+
const pc = new MidMapLagPeerConnection({
|
|
637
|
+
initialAudioMid: "1",
|
|
638
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:2\r\na=sendrecv\r\n",
|
|
639
|
+
});
|
|
640
|
+
pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
|
|
641
|
+
const session = {
|
|
642
|
+
sessionId: "alice|bob",
|
|
643
|
+
callerEns: "alice.secnum.global",
|
|
644
|
+
toIdentity: "bob.secnum.global",
|
|
645
|
+
peerConnection: pc,
|
|
646
|
+
localAudioTrack: localTrack,
|
|
647
|
+
};
|
|
648
|
+
const neg = new WebRtcNegotiation({
|
|
649
|
+
id: "alice",
|
|
650
|
+
endpoint: "alice.secnum.global",
|
|
651
|
+
session,
|
|
652
|
+
signaling,
|
|
653
|
+
primitives: deepLifecyclePrimitives(),
|
|
654
|
+
logger: silentLogger,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const offerWithAudioMid2 =
|
|
658
|
+
"v=0\r\n" +
|
|
659
|
+
"a=group:BUNDLE 0 2\r\n" +
|
|
660
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
661
|
+
"a=mid:0\r\n" +
|
|
662
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
663
|
+
"a=mid:2\r\n" +
|
|
664
|
+
"a=sendrecv\r\n";
|
|
665
|
+
|
|
666
|
+
await assert.doesNotReject(
|
|
667
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: offerWithAudioMid2 } }),
|
|
668
|
+
"recovery should survive mid=2 drift where transceiver maps lag behind exposed mids",
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("DEEP BUG REPRO: repeated decline/reuse can advance to mid=2 then mid=3", async () => {
|
|
673
|
+
const signaling = new FakeSignaling();
|
|
674
|
+
const localTrack = { kind: "audio" };
|
|
675
|
+
const pc = new MidMapLagPeerConnection({
|
|
676
|
+
initialAudioMid: "1",
|
|
677
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:3\r\na=sendrecv\r\n",
|
|
678
|
+
});
|
|
679
|
+
pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
|
|
680
|
+
const session = {
|
|
681
|
+
sessionId: "alice|bob",
|
|
682
|
+
callerEns: "alice.secnum.global",
|
|
683
|
+
toIdentity: "bob.secnum.global",
|
|
684
|
+
peerConnection: pc,
|
|
685
|
+
localAudioTrack: localTrack,
|
|
686
|
+
};
|
|
687
|
+
const neg = new WebRtcNegotiation({
|
|
688
|
+
id: "alice",
|
|
689
|
+
endpoint: "alice.secnum.global",
|
|
690
|
+
session,
|
|
691
|
+
signaling,
|
|
692
|
+
primitives: deepLifecyclePrimitives(),
|
|
693
|
+
logger: silentLogger,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const offerMid2 =
|
|
697
|
+
"v=0\r\n" +
|
|
698
|
+
"a=group:BUNDLE 0 2\r\n" +
|
|
699
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
700
|
+
"a=mid:0\r\n" +
|
|
701
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
702
|
+
"a=mid:2\r\n" +
|
|
703
|
+
"a=sendrecv\r\n";
|
|
704
|
+
const offerMid3 =
|
|
705
|
+
"v=0\r\n" +
|
|
706
|
+
"a=group:BUNDLE 0 3\r\n" +
|
|
707
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
708
|
+
"a=mid:0\r\n" +
|
|
709
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
710
|
+
"a=mid:3\r\n" +
|
|
711
|
+
"a=sendrecv\r\n";
|
|
712
|
+
|
|
713
|
+
await assert.doesNotReject(
|
|
714
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: offerMid2 } }),
|
|
715
|
+
"first reuse cycle should recover from mid=2 mismatch",
|
|
716
|
+
);
|
|
717
|
+
await assert.doesNotReject(
|
|
718
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: offerMid3 } }),
|
|
719
|
+
"second reuse cycle should recover from mid=3 mismatch",
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("DEEP MATRIX: createAnswer recovery handles missing mids 1/2/3", async () => {
|
|
724
|
+
for (const mid of ["1", "2", "3"]) {
|
|
725
|
+
const signaling = new FakeSignaling();
|
|
726
|
+
const session = {
|
|
727
|
+
sessionId: `alice|bob|create|${mid}`,
|
|
728
|
+
callerEns: "alice.secnum.global",
|
|
729
|
+
toIdentity: "bob.secnum.global",
|
|
730
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
731
|
+
throwOn: "createAnswer",
|
|
732
|
+
audioMid: "9",
|
|
733
|
+
answerSdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:${mid}\r\na=sendrecv\r\n`,
|
|
734
|
+
}),
|
|
735
|
+
localAudioTrack: null,
|
|
736
|
+
};
|
|
737
|
+
const neg = new WebRtcNegotiation({
|
|
738
|
+
id: "alice",
|
|
739
|
+
endpoint: "alice.secnum.global",
|
|
740
|
+
session,
|
|
741
|
+
signaling,
|
|
742
|
+
primitives: deepLifecyclePrimitives(),
|
|
743
|
+
logger: silentLogger,
|
|
744
|
+
});
|
|
745
|
+
const offer =
|
|
746
|
+
"v=0\r\n" +
|
|
747
|
+
`a=group:BUNDLE 0 ${mid}\r\n` +
|
|
748
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
749
|
+
"a=mid:0\r\n" +
|
|
750
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
751
|
+
`a=mid:${mid}\r\n` +
|
|
752
|
+
"a=sendrecv\r\n";
|
|
753
|
+
await assert.doesNotReject(
|
|
754
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: offer } }),
|
|
755
|
+
`createAnswer recovery should handle missing mid=${mid}`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("DEEP MATRIX: setLocalDescription recovery handles missing mids 1/2/3", async () => {
|
|
761
|
+
for (const mid of ["1", "2", "3"]) {
|
|
762
|
+
const signaling = new FakeSignaling();
|
|
763
|
+
const session = {
|
|
764
|
+
sessionId: `alice|bob|setlocal|${mid}`,
|
|
765
|
+
callerEns: "alice.secnum.global",
|
|
766
|
+
toIdentity: "bob.secnum.global",
|
|
767
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
768
|
+
throwOn: "setLocalDescription",
|
|
769
|
+
audioMid: "9",
|
|
770
|
+
answerSdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:${mid}\r\na=sendrecv\r\n`,
|
|
771
|
+
}),
|
|
772
|
+
localAudioTrack: null,
|
|
773
|
+
};
|
|
774
|
+
const neg = new WebRtcNegotiation({
|
|
775
|
+
id: "alice",
|
|
776
|
+
endpoint: "alice.secnum.global",
|
|
777
|
+
session,
|
|
778
|
+
signaling,
|
|
779
|
+
primitives: deepLifecyclePrimitives(),
|
|
780
|
+
logger: silentLogger,
|
|
781
|
+
});
|
|
782
|
+
const offer =
|
|
783
|
+
"v=0\r\n" +
|
|
784
|
+
`a=group:BUNDLE 0 ${mid}\r\n` +
|
|
785
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
786
|
+
"a=mid:0\r\n" +
|
|
787
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
788
|
+
`a=mid:${mid}\r\n` +
|
|
789
|
+
"a=sendrecv\r\n";
|
|
790
|
+
await assert.doesNotReject(
|
|
791
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: offer } }),
|
|
792
|
+
`setLocalDescription recovery should handle missing mid=${mid}`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("DEEP BUG REPRO: ice-restart after reuse survives stale mid map", async () => {
|
|
798
|
+
const signaling = new FakeSignaling();
|
|
799
|
+
const session = {
|
|
800
|
+
sessionId: "alice|bob|icerestart",
|
|
801
|
+
callerEns: "alice.secnum.global",
|
|
802
|
+
toIdentity: "bob.secnum.global",
|
|
803
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
804
|
+
throwOn: "setLocalDescription",
|
|
805
|
+
audioMid: "9",
|
|
806
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:2\r\na=sendrecv\r\n",
|
|
807
|
+
}),
|
|
808
|
+
localAudioTrack: { kind: "audio" },
|
|
809
|
+
};
|
|
810
|
+
const neg = new WebRtcNegotiation({
|
|
811
|
+
id: "alice",
|
|
812
|
+
endpoint: "alice.secnum.global",
|
|
813
|
+
session,
|
|
814
|
+
signaling,
|
|
815
|
+
primitives: deepLifecyclePrimitives(),
|
|
816
|
+
logger: silentLogger,
|
|
817
|
+
});
|
|
818
|
+
const restartOffer =
|
|
819
|
+
"v=0\r\n" +
|
|
820
|
+
"a=group:BUNDLE 0 2\r\n" +
|
|
821
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
822
|
+
"a=mid:0\r\n" +
|
|
823
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
824
|
+
"a=mid:2\r\n" +
|
|
825
|
+
"a=sendrecv\r\n";
|
|
826
|
+
await assert.doesNotReject(
|
|
827
|
+
() => neg.applyOffer({ mode: "ice-restart", payload: { sdp: restartOffer } }),
|
|
828
|
+
"ice-restart path should recover from stale reused mid mappings",
|
|
829
|
+
);
|
|
830
|
+
});
|
|
@@ -178,18 +178,7 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
178
178
|
offerSdp = this.p.patchInactiveToSendrecv(offerSdp);
|
|
179
179
|
}
|
|
180
180
|
if (!isIceRestart) this._ensureAudioReadyForOffer(offerSdp, pc);
|
|
181
|
-
|
|
182
|
-
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
183
|
-
} catch (err) {
|
|
184
|
-
if (this._isMissingMidError(err) && this._offerHasAudioMLine(offerSdp)) {
|
|
185
|
-
// Recovery for reused sessions that retained DC but drifted the audio MID
|
|
186
|
-
// mapping after end/reuse renegotiations.
|
|
187
|
-
this._repairAudioForOfferMid(offerSdp, pc);
|
|
188
|
-
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
189
|
-
} else {
|
|
190
|
-
throw err;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
181
|
+
await this._setRemoteOfferWithRecovery(pc, offerSdp);
|
|
193
182
|
await this.p.addIceCandidates?.(pc, payload.candidates || []);
|
|
194
183
|
// On ICE restart the track set is unchanged; only the caller-ring path
|
|
195
184
|
// needs to (re)attach the local audio track.
|
|
@@ -197,19 +186,7 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
197
186
|
this.p.ensureLocalAudioTrack(this.session, pc, this.id);
|
|
198
187
|
}
|
|
199
188
|
const label = isIceRestart ? "ICE-RESTART ANSWER SDP" : "ANSWER SDP";
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
answerSdp = await this.p.createAnswerSdp(pc, this.id, label);
|
|
203
|
-
} catch (err) {
|
|
204
|
-
if (this._isMissingMidError(err) && this._offerHasAudioMLine(offerSdp)) {
|
|
205
|
-
// Some stacks surface the same MID mismatch only when generating/applying
|
|
206
|
-
// the local answer, not on setRemoteDescription.
|
|
207
|
-
this._repairAudioForOfferMid(offerSdp, pc);
|
|
208
|
-
answerSdp = await this.p.createAnswerSdp(pc, this.id, label);
|
|
209
|
-
} else {
|
|
210
|
-
throw err;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
189
|
+
const answerSdp = await this._createAnswerWithRecovery(pc, offerSdp, label);
|
|
213
190
|
this.session.lastAnswerSdp = answerSdp;
|
|
214
191
|
// In-call renegotiation (ICE restart) answers immediately. For a fresh
|
|
215
192
|
// ring we hold the answer: the caller must not see "connected" until the
|
|
@@ -254,6 +231,55 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
254
231
|
return /Transceiver with mid=\d+ not found/i.test(msg);
|
|
255
232
|
}
|
|
256
233
|
|
|
234
|
+
_extractMissingMidFromError(err) {
|
|
235
|
+
const msg = String(err?.message || "");
|
|
236
|
+
const mid = msg.match(/Transceiver with mid=(\d+) not found/i)?.[1];
|
|
237
|
+
return mid || null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async _setRemoteOfferWithRecovery(pc, offerSdp) {
|
|
241
|
+
let forceCreate = false;
|
|
242
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
243
|
+
try {
|
|
244
|
+
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
245
|
+
return;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (!this._isMissingMidError(err) || !this._offerHasAudioMLine(offerSdp)) throw err;
|
|
248
|
+
const missingMid = this._extractMissingMidFromError(err);
|
|
249
|
+
this._repairAudioForOfferMid(offerSdp, pc, { preferredMid: missingMid, forceCreate });
|
|
250
|
+
forceCreate = true;
|
|
251
|
+
if (attempt === 2) throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async _createAnswerWithRecovery(pc, offerSdp, label) {
|
|
257
|
+
let forceCreate = false;
|
|
258
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
259
|
+
try {
|
|
260
|
+
return await this.p.createAnswerSdp(pc, this.id, label);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (!this._isMissingMidError(err) || !this._offerHasAudioMLine(offerSdp)) throw err;
|
|
263
|
+
const missingMid = this._extractMissingMidFromError(err);
|
|
264
|
+
this._repairAudioForOfferMid(offerSdp, pc, { preferredMid: missingMid, forceCreate });
|
|
265
|
+
forceCreate = true;
|
|
266
|
+
if (attempt === 2) throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_isTrackAlreadyAddedError(err) {
|
|
273
|
+
const msg = String(err?.message || "");
|
|
274
|
+
return /Track already added/i.test(msg);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_isTrackBoundToAudioTransceiver(pc, track) {
|
|
278
|
+
if (!track) return false;
|
|
279
|
+
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
280
|
+
return audioTransceivers.some((t) => t?.sender?.track === track);
|
|
281
|
+
}
|
|
282
|
+
|
|
257
283
|
_primeAudioTransceiver(transceiver, track) {
|
|
258
284
|
if (!transceiver) return;
|
|
259
285
|
try { transceiver.setDirection?.("sendrecv"); } catch (_) {}
|
|
@@ -262,9 +288,11 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
262
288
|
try { transceiver.sender?.replaceTrack?.(track); } catch (_) {}
|
|
263
289
|
}
|
|
264
290
|
|
|
265
|
-
_repairAudioForOfferMid(sdp, pc) {
|
|
291
|
+
_repairAudioForOfferMid(sdp, pc, opts = {}) {
|
|
266
292
|
this._ensureAudioReadyForOffer(sdp, pc, { force: true });
|
|
267
|
-
const offerMid = this._offerAudioMid(sdp);
|
|
293
|
+
const offerMid = String(opts.preferredMid || this._offerAudioMid(sdp) || "").trim() || null;
|
|
294
|
+
const forceCreate = opts.forceCreate === true;
|
|
295
|
+
const preferredFromError = String(opts.preferredMid || "").trim() || null;
|
|
268
296
|
const track = this.session.localAudioTrack || null;
|
|
269
297
|
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
270
298
|
for (const transceiver of audioTransceivers) this._primeAudioTransceiver(transceiver, track);
|
|
@@ -272,17 +300,21 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
272
300
|
|
|
273
301
|
const hasExactMid = () =>
|
|
274
302
|
(pc.getTransceivers?.().some((t) => t.kind === "audio" && String(t.mid || "") === offerMid) ?? false);
|
|
275
|
-
if (hasExactMid()) return;
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
303
|
+
if (hasExactMid() && !forceCreate) return;
|
|
304
|
+
|
|
305
|
+
// If the stack explicitly reported a missing MID, prefer creating/syncing that
|
|
306
|
+
// MID before retagging existing transceivers. Some implementations keep an
|
|
307
|
+
// internal MID map that lags behind exposed transceiver.mid changes.
|
|
308
|
+
const canRetagExisting = !preferredFromError;
|
|
309
|
+
if (canRetagExisting) {
|
|
310
|
+
const candidate =
|
|
311
|
+
audioTransceivers.find((t) => t.mid == null || t.mid === "") ||
|
|
312
|
+
audioTransceivers[0];
|
|
313
|
+
if (candidate && String(candidate.mid || "") !== offerMid) {
|
|
314
|
+
try { candidate.mid = offerMid; } catch (_) {}
|
|
315
|
+
}
|
|
316
|
+
if (hasExactMid() && !forceCreate) return;
|
|
284
317
|
}
|
|
285
|
-
if (hasExactMid()) return;
|
|
286
318
|
|
|
287
319
|
// Final fallback: try creating/binding a fresh audio transceiver if the
|
|
288
320
|
// implementation supports it.
|
|
@@ -297,20 +329,41 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
297
329
|
}
|
|
298
330
|
} catch (_) {}
|
|
299
331
|
}
|
|
332
|
+
|
|
333
|
+
if (!hasExactMid()) {
|
|
334
|
+
const candidate =
|
|
335
|
+
(pc.getTransceivers?.().find((t) => t.kind === "audio" && (t.mid == null || t.mid === ""))) ||
|
|
336
|
+
(pc.getTransceivers?.().find((t) => t.kind === "audio")) ||
|
|
337
|
+
null;
|
|
338
|
+
if (candidate && String(candidate.mid || "") !== offerMid) {
|
|
339
|
+
try { candidate.mid = offerMid; } catch (_) {}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
300
342
|
}
|
|
301
343
|
|
|
302
344
|
_ensureAudioReadyForOffer(sdp, pc, opts = {}) {
|
|
303
345
|
if (!this._offerHasAudioMLine(sdp)) return;
|
|
304
346
|
const force = opts.force === true;
|
|
305
347
|
const audioTransceiver = pc.getTransceivers?.().find((t) => t.kind === "audio");
|
|
306
|
-
if (audioTransceiver && !force)
|
|
348
|
+
if (audioTransceiver && !force) {
|
|
349
|
+
this._primeAudioTransceiver(audioTransceiver, this.session.localAudioTrack || null);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
307
352
|
|
|
308
353
|
if (!this.session.localAudioTrack) {
|
|
309
354
|
this.session.localAudioTrack = new this.MediaStreamTrack({ kind: "audio" });
|
|
310
355
|
}
|
|
311
|
-
|
|
356
|
+
const track = this.session.localAudioTrack;
|
|
357
|
+
const alreadyBound = this._isTrackBoundToAudioTransceiver(pc, track);
|
|
358
|
+
if (!alreadyBound && typeof pc.addTrack === "function") {
|
|
359
|
+
try {
|
|
360
|
+
pc.addTrack(track);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (!this._isTrackAlreadyAddedError(err)) throw err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
312
365
|
const latestAudio = pc.getTransceivers?.().find((t) => t.kind === "audio");
|
|
313
|
-
if (latestAudio) this._primeAudioTransceiver(latestAudio,
|
|
366
|
+
if (latestAudio) this._primeAudioTransceiver(latestAudio, track);
|
|
314
367
|
}
|
|
315
368
|
|
|
316
369
|
// The caller's client offered a ring and we are connected: ack it so the
|