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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arnacon-webrtc-service",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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,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 (!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,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) return;
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
- pc.addTrack?.(this.session.localAudioTrack);
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