arnacon-webrtc-service 0.2.0 → 0.2.1
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,88 @@ 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
|
+
function deepLifecyclePrimitives() {
|
|
122
|
+
const base = fakePrimitives();
|
|
123
|
+
const callSdpUseCases = new CallSdpUseCases({
|
|
124
|
+
sessions: new Map(),
|
|
125
|
+
MediaStreamTrack: base.MediaStreamTrack,
|
|
126
|
+
patchInactiveToSendrecv: base.patchInactiveToSendrecv,
|
|
127
|
+
logSdp: () => {},
|
|
128
|
+
enqueueSignaling: (_sessionId, _label, fn) => Promise.resolve().then(fn),
|
|
129
|
+
sendDataChannelMessage: () => {},
|
|
130
|
+
callRuntime: null,
|
|
131
|
+
logger: silentLogger,
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
ensureLocalAudioTrack: (...args) => callSdpUseCases.ensureLocalAudioTrack(...args),
|
|
136
|
+
createAnswerSdp: (...args) => callSdpUseCases.createAnswerSdp(...args),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
57
140
|
test("ring sends a signaling offer with the caller's bare label", async () => {
|
|
58
141
|
const { neg, signaling } = build();
|
|
59
142
|
await neg.ring({ from: "alice.secnum.global" });
|
|
@@ -317,3 +400,77 @@ test("BUG REPRO: redial offer after end/reuse must not fail on mid mismatch", as
|
|
|
317
400
|
"redial offer should recover from transceiver mismatch instead of wedging",
|
|
318
401
|
);
|
|
319
402
|
});
|
|
403
|
+
|
|
404
|
+
test("DEEP BUG REPRO: stale audio MID can fail during createAnswer (not only setRemoteDescription)", async () => {
|
|
405
|
+
const signaling = new FakeSignaling();
|
|
406
|
+
const session = {
|
|
407
|
+
sessionId: "alice|bob",
|
|
408
|
+
callerEns: "alice.secnum.global",
|
|
409
|
+
toIdentity: "bob.secnum.global",
|
|
410
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
411
|
+
throwOn: "createAnswer",
|
|
412
|
+
audioMid: "2", // stale vs incoming offer's mid=1
|
|
413
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
414
|
+
}),
|
|
415
|
+
localAudioTrack: null,
|
|
416
|
+
};
|
|
417
|
+
const neg = new WebRtcNegotiation({
|
|
418
|
+
id: "alice",
|
|
419
|
+
endpoint: "alice.secnum.global",
|
|
420
|
+
session,
|
|
421
|
+
signaling,
|
|
422
|
+
primitives: deepLifecyclePrimitives(),
|
|
423
|
+
logger: silentLogger,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const redialOfferWithAudioMid =
|
|
427
|
+
"v=0\r\n" +
|
|
428
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
429
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
430
|
+
"a=mid:0\r\n" +
|
|
431
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
432
|
+
"a=mid:1\r\n" +
|
|
433
|
+
"a=sendrecv\r\n";
|
|
434
|
+
|
|
435
|
+
await assert.doesNotReject(
|
|
436
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
437
|
+
"redial offer should not wedge even when MID mismatch surfaces at createAnswer time",
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("DEEP BUG REPRO: stale audio MID can fail during setLocalDescription", async () => {
|
|
442
|
+
const signaling = new FakeSignaling();
|
|
443
|
+
const session = {
|
|
444
|
+
sessionId: "alice|bob",
|
|
445
|
+
callerEns: "alice.secnum.global",
|
|
446
|
+
toIdentity: "bob.secnum.global",
|
|
447
|
+
peerConnection: new MidLifecyclePeerConnection({
|
|
448
|
+
throwOn: "setLocalDescription",
|
|
449
|
+
audioMid: "2", // stale vs incoming offer's mid=1
|
|
450
|
+
answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:1\r\na=sendrecv\r\n",
|
|
451
|
+
}),
|
|
452
|
+
localAudioTrack: null,
|
|
453
|
+
};
|
|
454
|
+
const neg = new WebRtcNegotiation({
|
|
455
|
+
id: "alice",
|
|
456
|
+
endpoint: "alice.secnum.global",
|
|
457
|
+
session,
|
|
458
|
+
signaling,
|
|
459
|
+
primitives: deepLifecyclePrimitives(),
|
|
460
|
+
logger: silentLogger,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const redialOfferWithAudioMid =
|
|
464
|
+
"v=0\r\n" +
|
|
465
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
466
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
467
|
+
"a=mid:0\r\n" +
|
|
468
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
469
|
+
"a=mid:1\r\n" +
|
|
470
|
+
"a=sendrecv\r\n";
|
|
471
|
+
|
|
472
|
+
await assert.doesNotReject(
|
|
473
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: redialOfferWithAudioMid } }),
|
|
474
|
+
"redial offer should not wedge even when MID mismatch surfaces at setLocalDescription time",
|
|
475
|
+
);
|
|
476
|
+
});
|
|
@@ -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,11 +238,67 @@ 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
|
+
_primeAudioTransceiver(transceiver, track) {
|
|
258
|
+
if (!transceiver) return;
|
|
259
|
+
try { transceiver.setDirection?.("sendrecv"); } catch (_) {}
|
|
260
|
+
if (!track) return;
|
|
261
|
+
try { transceiver.sender?.registerTrack?.(track); } catch (_) {}
|
|
262
|
+
try { transceiver.sender?.replaceTrack?.(track); } catch (_) {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_repairAudioForOfferMid(sdp, pc) {
|
|
266
|
+
this._ensureAudioReadyForOffer(sdp, pc, { force: true });
|
|
267
|
+
const offerMid = this._offerAudioMid(sdp);
|
|
268
|
+
const track = this.session.localAudioTrack || null;
|
|
269
|
+
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
270
|
+
for (const transceiver of audioTransceivers) this._primeAudioTransceiver(transceiver, track);
|
|
271
|
+
if (!offerMid || audioTransceivers.length === 0) return;
|
|
272
|
+
|
|
273
|
+
const hasExactMid = () =>
|
|
274
|
+
(pc.getTransceivers?.().some((t) => t.kind === "audio" && String(t.mid || "") === offerMid) ?? false);
|
|
275
|
+
if (hasExactMid()) return;
|
|
276
|
+
|
|
277
|
+
// Best-effort realignment for reused PCs: if we have one audio transceiver but
|
|
278
|
+
// its MID drifted, align it to the current offer's audio MID.
|
|
279
|
+
const candidate =
|
|
280
|
+
audioTransceivers.find((t) => t.mid == null || t.mid === "") ||
|
|
281
|
+
audioTransceivers[0];
|
|
282
|
+
if (candidate && String(candidate.mid || "") !== offerMid) {
|
|
283
|
+
try { candidate.mid = offerMid; } catch (_) {}
|
|
284
|
+
}
|
|
285
|
+
if (hasExactMid()) return;
|
|
286
|
+
|
|
287
|
+
// Final fallback: try creating/binding a fresh audio transceiver if the
|
|
288
|
+
// implementation supports it.
|
|
289
|
+
if (typeof pc.addTransceiver === "function") {
|
|
290
|
+
try {
|
|
291
|
+
const created = track
|
|
292
|
+
? pc.addTransceiver(track, { direction: "sendrecv" })
|
|
293
|
+
: pc.addTransceiver("audio", { direction: "sendrecv" });
|
|
294
|
+
if (created) {
|
|
295
|
+
try { created.mid = offerMid; } catch (_) {}
|
|
296
|
+
this._primeAudioTransceiver(created, track);
|
|
297
|
+
}
|
|
298
|
+
} catch (_) {}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
233
302
|
_ensureAudioReadyForOffer(sdp, pc, opts = {}) {
|
|
234
303
|
if (!this._offerHasAudioMLine(sdp)) return;
|
|
235
304
|
const force = opts.force === true;
|
|
@@ -240,6 +309,8 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
240
309
|
this.session.localAudioTrack = new this.MediaStreamTrack({ kind: "audio" });
|
|
241
310
|
}
|
|
242
311
|
pc.addTrack?.(this.session.localAudioTrack);
|
|
312
|
+
const latestAudio = pc.getTransceivers?.().find((t) => t.kind === "audio");
|
|
313
|
+
if (latestAudio) this._primeAudioTransceiver(latestAudio, this.session.localAudioTrack);
|
|
243
314
|
}
|
|
244
315
|
|
|
245
316
|
// The caller's client offered a ring and we are connected: ack it so the
|