arnacon-webrtc-service 0.2.0 → 0.2.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/package.json
CHANGED
|
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
|
|
|
3
3
|
|
|
4
4
|
const { WebRtcNegotiation } = require("../negotiation/WebRtcNegotiation");
|
|
5
5
|
const { FakeSignaling, FakePeerConnection, fakePrimitives, silentLogger } = require("./fakes");
|
|
6
|
+
const { CallSdpUseCases } = require("../../useCases/CallSdpUseCases");
|
|
6
7
|
|
|
7
8
|
function build({ offerSdp, answerSdp } = {}) {
|
|
8
9
|
const signaling = new FakeSignaling();
|
|
@@ -54,6 +55,133 @@ class MidStrictPeerConnection extends FakePeerConnection {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
class MidLifecyclePeerConnection extends FakePeerConnection {
|
|
59
|
+
constructor({ throwOn = "none", audioMid = "2", ...opts } = {}) {
|
|
60
|
+
super(opts);
|
|
61
|
+
this.throwOn = throwOn;
|
|
62
|
+
this.transceivers = [
|
|
63
|
+
{ kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {} } },
|
|
64
|
+
{ kind: "audio", mid: audioMid, setDirection() {}, sender: { replaceTrack: async () => {} } },
|
|
65
|
+
];
|
|
66
|
+
this._remoteOfferAudioMid = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_extractAudioMid(sdp = "") {
|
|
70
|
+
const sections = String(sdp).split(/\r?\nm=/);
|
|
71
|
+
for (const rawSection of sections) {
|
|
72
|
+
const section = rawSection.startsWith("m=") ? rawSection : `m=${rawSection}`;
|
|
73
|
+
if (!/^m=audio\b/m.test(section)) continue;
|
|
74
|
+
const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1];
|
|
75
|
+
if (mid) return mid;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_hasTransceiverMid(mid) {
|
|
81
|
+
if (!mid) return true;
|
|
82
|
+
return this.transceivers.some((t) => String(t.mid) === String(mid));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
addTrack(track) {
|
|
86
|
+
// Simulate a stale transceiver map on reused sessions: addTrack does not
|
|
87
|
+
// necessarily repair a wrong/missing negotiated MID binding.
|
|
88
|
+
return { kind: track?.kind || "audio" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async setRemoteDescription(d) {
|
|
92
|
+
if (d?.type === "offer") {
|
|
93
|
+
this._remoteOfferAudioMid = this._extractAudioMid(d?.sdp || "");
|
|
94
|
+
}
|
|
95
|
+
this.remoteDescription = d;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async createAnswer() {
|
|
99
|
+
if (
|
|
100
|
+
this.throwOn === "createAnswer" &&
|
|
101
|
+
this._remoteOfferAudioMid &&
|
|
102
|
+
!this._hasTransceiverMid(this._remoteOfferAudioMid)
|
|
103
|
+
) {
|
|
104
|
+
throw new Error(`Transceiver with mid=${this._remoteOfferAudioMid} not found`);
|
|
105
|
+
}
|
|
106
|
+
return { type: "answer", sdp: this._answerSdp };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async setLocalDescription(d) {
|
|
110
|
+
if (
|
|
111
|
+
this.throwOn === "setLocalDescription" &&
|
|
112
|
+
this._remoteOfferAudioMid &&
|
|
113
|
+
!this._hasTransceiverMid(this._remoteOfferAudioMid)
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(`Transceiver with mid=${this._remoteOfferAudioMid} not found`);
|
|
116
|
+
}
|
|
117
|
+
this.localDescription = d;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
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
|
+
function deepLifecyclePrimitives() {
|
|
167
|
+
const base = fakePrimitives();
|
|
168
|
+
const callSdpUseCases = new CallSdpUseCases({
|
|
169
|
+
sessions: new Map(),
|
|
170
|
+
MediaStreamTrack: base.MediaStreamTrack,
|
|
171
|
+
patchInactiveToSendrecv: base.patchInactiveToSendrecv,
|
|
172
|
+
logSdp: () => {},
|
|
173
|
+
enqueueSignaling: (_sessionId, _label, fn) => Promise.resolve().then(fn),
|
|
174
|
+
sendDataChannelMessage: () => {},
|
|
175
|
+
callRuntime: null,
|
|
176
|
+
logger: silentLogger,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
ensureLocalAudioTrack: (...args) => callSdpUseCases.ensureLocalAudioTrack(...args),
|
|
181
|
+
createAnswerSdp: (...args) => callSdpUseCases.createAnswerSdp(...args),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
57
185
|
test("ring sends a signaling offer with the caller's bare label", async () => {
|
|
58
186
|
const { neg, signaling } = build();
|
|
59
187
|
await neg.ring({ from: "alice.secnum.global" });
|
|
@@ -317,3 +445,116 @@ test("BUG REPRO: redial offer after end/reuse must not fail on mid mismatch", as
|
|
|
317
445
|
"redial offer should recover from transceiver mismatch instead of wedging",
|
|
318
446
|
);
|
|
319
447
|
});
|
|
448
|
+
|
|
449
|
+
test("DEEP BUG REPRO: stale audio MID can fail during createAnswer (not only setRemoteDescription)", async () => {
|
|
450
|
+
const signaling = new FakeSignaling();
|
|
451
|
+
const session = {
|
|
452
|
+
sessionId: "alice|bob",
|
|
453
|
+
callerEns: "alice.secnum.global",
|
|
454
|
+
toIdentity: "bob.secnum.global",
|
|
455
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
456
|
+
throwOn: "createAnswer",
|
|
457
|
+
audioMid: "2", // stale vs incoming offer's mid=1
|
|
458
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
459
|
+
}),
|
|
460
|
+
localAudioTrack: null,
|
|
461
|
+
};
|
|
462
|
+
const neg = new WebRtcNegotiation({
|
|
463
|
+
id: "alice",
|
|
464
|
+
endpoint: "alice.secnum.global",
|
|
465
|
+
session,
|
|
466
|
+
signaling,
|
|
467
|
+
primitives: deepLifecyclePrimitives(),
|
|
468
|
+
logger: silentLogger,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const redialOfferWithAudioMid =
|
|
472
|
+
"v=0\r\n" +
|
|
473
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
474
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
475
|
+
"a=mid:0\r\n" +
|
|
476
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
477
|
+
"a=mid:1\r\n" +
|
|
478
|
+
"a=sendrecv\r\n";
|
|
479
|
+
|
|
480
|
+
await assert.doesNotReject(
|
|
481
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
482
|
+
"redial offer should not wedge even when MID mismatch surfaces at createAnswer time",
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("DEEP BUG REPRO: stale audio MID can fail during setLocalDescription", async () => {
|
|
487
|
+
const signaling = new FakeSignaling();
|
|
488
|
+
const session = {
|
|
489
|
+
sessionId: "alice|bob",
|
|
490
|
+
callerEns: "alice.secnum.global",
|
|
491
|
+
toIdentity: "bob.secnum.global",
|
|
492
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
493
|
+
throwOn: "setLocalDescription",
|
|
494
|
+
audioMid: "2", // stale vs incoming offer's mid=1
|
|
495
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
496
|
+
}),
|
|
497
|
+
localAudioTrack: null,
|
|
498
|
+
};
|
|
499
|
+
const neg = new WebRtcNegotiation({
|
|
500
|
+
id: "alice",
|
|
501
|
+
endpoint: "alice.secnum.global",
|
|
502
|
+
session,
|
|
503
|
+
signaling,
|
|
504
|
+
primitives: deepLifecyclePrimitives(),
|
|
505
|
+
logger: silentLogger,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const redialOfferWithAudioMid =
|
|
509
|
+
"v=0\r\n" +
|
|
510
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
511
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
512
|
+
"a=mid:0\r\n" +
|
|
513
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
514
|
+
"a=mid:1\r\n" +
|
|
515
|
+
"a=sendrecv\r\n";
|
|
516
|
+
|
|
517
|
+
await assert.doesNotReject(
|
|
518
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
519
|
+
"redial offer should not wedge even when MID mismatch surfaces at setLocalDescription time",
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("DEEP BUG REPRO: recovery must not throw when local track is already bound", async () => {
|
|
524
|
+
const signaling = new FakeSignaling();
|
|
525
|
+
const localTrack = { kind: "audio" };
|
|
526
|
+
const pc = new DuplicateTrackOnRecoveryPeerConnection({
|
|
527
|
+
audioMid: "2",
|
|
528
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
529
|
+
});
|
|
530
|
+
pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
|
|
531
|
+
const session = {
|
|
532
|
+
sessionId: "alice|bob",
|
|
533
|
+
callerEns: "alice.secnum.global",
|
|
534
|
+
toIdentity: "bob.secnum.global",
|
|
535
|
+
peerConnection: pc,
|
|
536
|
+
localAudioTrack: localTrack,
|
|
537
|
+
};
|
|
538
|
+
const neg = new WebRtcNegotiation({
|
|
539
|
+
id: "alice",
|
|
540
|
+
endpoint: "alice.secnum.global",
|
|
541
|
+
session,
|
|
542
|
+
signaling,
|
|
543
|
+
primitives: deepLifecyclePrimitives(),
|
|
544
|
+
logger: silentLogger,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const redialOfferWithAudioMid =
|
|
548
|
+
"v=0\r\n" +
|
|
549
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
550
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
551
|
+
"a=mid:0\r\n" +
|
|
552
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
553
|
+
"a=mid:1\r\n" +
|
|
554
|
+
"a=sendrecv\r\n";
|
|
555
|
+
|
|
556
|
+
await assert.doesNotReject(
|
|
557
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
558
|
+
"recovery should tolerate already-bound tracks and continue",
|
|
559
|
+
);
|
|
560
|
+
});
|
|
@@ -181,9 +181,10 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
181
181
|
try {
|
|
182
182
|
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
183
183
|
} catch (err) {
|
|
184
|
-
if (
|
|
185
|
-
// Recovery for reused sessions that retained DC but
|
|
186
|
-
|
|
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);
|
|
187
188
|
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
188
189
|
} else {
|
|
189
190
|
throw err;
|
|
@@ -196,7 +197,19 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
196
197
|
this.p.ensureLocalAudioTrack(this.session, pc, this.id);
|
|
197
198
|
}
|
|
198
199
|
const label = isIceRestart ? "ICE-RESTART ANSWER SDP" : "ANSWER SDP";
|
|
199
|
-
|
|
200
|
+
let answerSdp;
|
|
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
|
+
}
|
|
200
213
|
this.session.lastAnswerSdp = answerSdp;
|
|
201
214
|
// In-call renegotiation (ICE restart) answers immediately. For a fresh
|
|
202
215
|
// ring we hold the answer: the caller must not see "connected" until the
|
|
@@ -225,21 +238,101 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
225
238
|
return /\bm=audio\b/.test(sdp);
|
|
226
239
|
}
|
|
227
240
|
|
|
241
|
+
_offerAudioMid(sdp = "") {
|
|
242
|
+
const sections = String(sdp).split(/\r?\nm=/);
|
|
243
|
+
for (const rawSection of sections) {
|
|
244
|
+
const section = rawSection.startsWith("m=") ? rawSection : `m=${rawSection}`;
|
|
245
|
+
if (!/^m=audio\b/m.test(section)) continue;
|
|
246
|
+
const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1];
|
|
247
|
+
if (mid) return String(mid).trim();
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
228
252
|
_isMissingMidError(err) {
|
|
229
253
|
const msg = String(err?.message || "");
|
|
230
254
|
return /Transceiver with mid=\d+ not found/i.test(msg);
|
|
231
255
|
}
|
|
232
256
|
|
|
257
|
+
_isTrackAlreadyAddedError(err) {
|
|
258
|
+
const msg = String(err?.message || "");
|
|
259
|
+
return /Track already added/i.test(msg);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_isTrackBoundToAudioTransceiver(pc, track) {
|
|
263
|
+
if (!track) return false;
|
|
264
|
+
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
265
|
+
return audioTransceivers.some((t) => t?.sender?.track === track);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_primeAudioTransceiver(transceiver, track) {
|
|
269
|
+
if (!transceiver) return;
|
|
270
|
+
try { transceiver.setDirection?.("sendrecv"); } catch (_) {}
|
|
271
|
+
if (!track) return;
|
|
272
|
+
try { transceiver.sender?.registerTrack?.(track); } catch (_) {}
|
|
273
|
+
try { transceiver.sender?.replaceTrack?.(track); } catch (_) {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_repairAudioForOfferMid(sdp, pc) {
|
|
277
|
+
this._ensureAudioReadyForOffer(sdp, pc, { force: true });
|
|
278
|
+
const offerMid = this._offerAudioMid(sdp);
|
|
279
|
+
const track = this.session.localAudioTrack || null;
|
|
280
|
+
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
281
|
+
for (const transceiver of audioTransceivers) this._primeAudioTransceiver(transceiver, track);
|
|
282
|
+
if (!offerMid || audioTransceivers.length === 0) return;
|
|
283
|
+
|
|
284
|
+
const hasExactMid = () =>
|
|
285
|
+
(pc.getTransceivers?.().some((t) => t.kind === "audio" && String(t.mid || "") === offerMid) ?? false);
|
|
286
|
+
if (hasExactMid()) return;
|
|
287
|
+
|
|
288
|
+
// Best-effort realignment for reused PCs: if we have one audio transceiver but
|
|
289
|
+
// its MID drifted, align it to the current offer's audio MID.
|
|
290
|
+
const candidate =
|
|
291
|
+
audioTransceivers.find((t) => t.mid == null || t.mid === "") ||
|
|
292
|
+
audioTransceivers[0];
|
|
293
|
+
if (candidate && String(candidate.mid || "") !== offerMid) {
|
|
294
|
+
try { candidate.mid = offerMid; } catch (_) {}
|
|
295
|
+
}
|
|
296
|
+
if (hasExactMid()) return;
|
|
297
|
+
|
|
298
|
+
// Final fallback: try creating/binding a fresh audio transceiver if the
|
|
299
|
+
// implementation supports it.
|
|
300
|
+
if (typeof pc.addTransceiver === "function") {
|
|
301
|
+
try {
|
|
302
|
+
const created = track
|
|
303
|
+
? pc.addTransceiver(track, { direction: "sendrecv" })
|
|
304
|
+
: pc.addTransceiver("audio", { direction: "sendrecv" });
|
|
305
|
+
if (created) {
|
|
306
|
+
try { created.mid = offerMid; } catch (_) {}
|
|
307
|
+
this._primeAudioTransceiver(created, track);
|
|
308
|
+
}
|
|
309
|
+
} catch (_) {}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
233
313
|
_ensureAudioReadyForOffer(sdp, pc, opts = {}) {
|
|
234
314
|
if (!this._offerHasAudioMLine(sdp)) return;
|
|
235
315
|
const force = opts.force === true;
|
|
236
316
|
const audioTransceiver = pc.getTransceivers?.().find((t) => t.kind === "audio");
|
|
237
|
-
if (audioTransceiver && !force)
|
|
317
|
+
if (audioTransceiver && !force) {
|
|
318
|
+
this._primeAudioTransceiver(audioTransceiver, this.session.localAudioTrack || null);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
238
321
|
|
|
239
322
|
if (!this.session.localAudioTrack) {
|
|
240
323
|
this.session.localAudioTrack = new this.MediaStreamTrack({ kind: "audio" });
|
|
241
324
|
}
|
|
242
|
-
|
|
325
|
+
const track = this.session.localAudioTrack;
|
|
326
|
+
const alreadyBound = this._isTrackBoundToAudioTransceiver(pc, track);
|
|
327
|
+
if (!alreadyBound && typeof pc.addTrack === "function") {
|
|
328
|
+
try {
|
|
329
|
+
pc.addTrack(track);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (!this._isTrackAlreadyAddedError(err)) throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const latestAudio = pc.getTransceivers?.().find((t) => t.kind === "audio");
|
|
335
|
+
if (latestAudio) this._primeAudioTransceiver(latestAudio, track);
|
|
243
336
|
}
|
|
244
337
|
|
|
245
338
|
// The caller's client offered a ring and we are connected: ack it so the
|