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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arnacon-webrtc-service",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -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 (!isIceRestart && this._isMissingMidError(err) && this._offerHasAudioMLine(offerSdp)) {
185
- // Recovery for reused sessions that retained DC but lost local m=audio mapping.
186
- this._ensureAudioReadyForOffer(offerSdp, pc, { force: true });
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
- const answerSdp = await this.p.createAnswerSdp(pc, this.id, label);
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