arnacon-webrtc-service 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -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
+ });
@@ -4,6 +4,7 @@ const assert = require("node:assert/strict");
4
4
  const { WebRtcNegotiation } = require("../negotiation/WebRtcNegotiation");
5
5
  const { FakeSignaling, FakePeerConnection, fakePrimitives, silentLogger } = require("./fakes");
6
6
  const { CallSdpUseCases } = require("../../useCases/CallSdpUseCases");
7
+ const { addIceCandidates: addIceCandidatesUtil } = require("../../../media/negotiation/SdpUtils");
7
8
 
8
9
  function build({ offerSdp, answerSdp } = {}) {
9
10
  const signaling = new FakeSignaling();
@@ -163,6 +164,99 @@ class DuplicateTrackOnRecoveryPeerConnection extends FakePeerConnection {
163
164
  }
164
165
  }
165
166
 
167
+ class MidMapLagPeerConnection extends FakePeerConnection {
168
+ constructor({ initialAudioMid = "1", ...opts } = {}) {
169
+ super(opts);
170
+ this.transceivers = [
171
+ { kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {} } },
172
+ { kind: "audio", mid: initialAudioMid, setDirection() {}, sender: { replaceTrack: async () => {}, track: null } },
173
+ ];
174
+ this.acceptedAudioMids = new Set([String(initialAudioMid)]);
175
+ this._remoteOfferAudioMid = null;
176
+ }
177
+
178
+ _extractAudioMid(sdp = "") {
179
+ const sections = String(sdp).split(/\r?\nm=/);
180
+ for (const rawSection of sections) {
181
+ const section = rawSection.startsWith("m=") ? rawSection : `m=${rawSection}`;
182
+ if (!/^m=audio\b/m.test(section)) continue;
183
+ const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1];
184
+ if (mid) return String(mid).trim();
185
+ }
186
+ return null;
187
+ }
188
+
189
+ addTrack(track) {
190
+ const audio = this.transceivers.find((t) => t.kind === "audio");
191
+ if (audio?.sender) audio.sender.track = track;
192
+ return { kind: track?.kind || "audio" };
193
+ }
194
+
195
+ addTransceiver(kindOrTrack) {
196
+ const isAudio = kindOrTrack === "audio" || kindOrTrack?.kind === "audio";
197
+ if (!isAudio) return null;
198
+ const existingNumericMids = this.transceivers
199
+ .map((t) => Number.parseInt(String(t.mid ?? ""), 10))
200
+ .filter((n) => Number.isFinite(n));
201
+ const nextMid = String(existingNumericMids.length ? Math.max(...existingNumericMids) + 1 : 1);
202
+ const created = {
203
+ kind: "audio",
204
+ setDirection() {},
205
+ sender: { replaceTrack: async () => {}, track: kindOrTrack?.kind === "audio" ? kindOrTrack : null },
206
+ };
207
+ let currentMid = nextMid;
208
+ Object.defineProperty(created, "mid", {
209
+ get() {
210
+ return currentMid;
211
+ },
212
+ set: (newMid) => {
213
+ const normalized = String(newMid ?? "").trim();
214
+ if (normalized === currentMid) return;
215
+ this.acceptedAudioMids.delete(currentMid);
216
+ currentMid = normalized;
217
+ if (currentMid) this.acceptedAudioMids.add(currentMid);
218
+ },
219
+ enumerable: true,
220
+ configurable: true,
221
+ });
222
+ this.transceivers.push(created);
223
+ this.acceptedAudioMids.add(nextMid);
224
+ return created;
225
+ }
226
+
227
+ async setRemoteDescription(d) {
228
+ if (d?.type === "offer") {
229
+ this._remoteOfferAudioMid = this._extractAudioMid(d?.sdp || "");
230
+ if (this._remoteOfferAudioMid && !this.acceptedAudioMids.has(this._remoteOfferAudioMid)) {
231
+ throw new Error(`Transceiver with mid=${this._remoteOfferAudioMid} not found`);
232
+ }
233
+ }
234
+ this.remoteDescription = d;
235
+ }
236
+ }
237
+
238
+ class CandidateMLineStrictPeerConnection extends FakePeerConnection {
239
+ constructor(opts = {}) {
240
+ super(opts);
241
+ this._remoteMLineCount = 0;
242
+ this.appliedCandidates = [];
243
+ }
244
+
245
+ async setRemoteDescription(d) {
246
+ this.remoteDescription = d;
247
+ const sdp = String(d?.sdp || "");
248
+ this._remoteMLineCount = (sdp.match(/^m=/gm) || []).length;
249
+ }
250
+
251
+ async addIceCandidate(candidate) {
252
+ const idx = Number(candidate?.sdpMLineIndex);
253
+ if (Number.isFinite(idx) && (idx < 0 || idx >= this._remoteMLineCount)) {
254
+ throw new Error("m line not found");
255
+ }
256
+ this.appliedCandidates.push(candidate);
257
+ }
258
+ }
259
+
166
260
  function deepLifecyclePrimitives() {
167
261
  const base = fakePrimitives();
168
262
  const callSdpUseCases = new CallSdpUseCases({
@@ -179,6 +273,12 @@ function deepLifecyclePrimitives() {
179
273
  ...base,
180
274
  ensureLocalAudioTrack: (...args) => callSdpUseCases.ensureLocalAudioTrack(...args),
181
275
  createAnswerSdp: (...args) => callSdpUseCases.createAnswerSdp(...args),
276
+ addIceCandidates: (...args) =>
277
+ addIceCandidatesUtil(...args, class RTCIceCandidate {
278
+ constructor(init = {}) {
279
+ Object.assign(this, init);
280
+ }
281
+ }),
182
282
  };
183
283
  }
184
284
 
@@ -558,3 +658,245 @@ test("DEEP BUG REPRO: recovery must not throw when local track is already bound"
558
658
  "recovery should tolerate already-bound tracks and continue",
559
659
  );
560
660
  });
661
+
662
+ test("DEEP BUG REPRO: mid=2 map-lag recovers via forced transceiver create", async () => {
663
+ const signaling = new FakeSignaling();
664
+ const localTrack = { kind: "audio" };
665
+ const pc = new MidMapLagPeerConnection({
666
+ initialAudioMid: "1",
667
+ answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:2\r\na=sendrecv\r\n",
668
+ });
669
+ pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
670
+ const session = {
671
+ sessionId: "alice|bob",
672
+ callerEns: "alice.secnum.global",
673
+ toIdentity: "bob.secnum.global",
674
+ peerConnection: pc,
675
+ localAudioTrack: localTrack,
676
+ };
677
+ const neg = new WebRtcNegotiation({
678
+ id: "alice",
679
+ endpoint: "alice.secnum.global",
680
+ session,
681
+ signaling,
682
+ primitives: deepLifecyclePrimitives(),
683
+ logger: silentLogger,
684
+ });
685
+
686
+ const offerWithAudioMid2 =
687
+ "v=0\r\n" +
688
+ "a=group:BUNDLE 0 2\r\n" +
689
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
690
+ "a=mid:0\r\n" +
691
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
692
+ "a=mid:2\r\n" +
693
+ "a=sendrecv\r\n";
694
+
695
+ await assert.doesNotReject(
696
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offerWithAudioMid2 } }),
697
+ "recovery should survive mid=2 drift where transceiver maps lag behind exposed mids",
698
+ );
699
+ });
700
+
701
+ test("DEEP BUG REPRO: repeated decline/reuse can advance to mid=2 then mid=3", async () => {
702
+ const signaling = new FakeSignaling();
703
+ const localTrack = { kind: "audio" };
704
+ const pc = new MidMapLagPeerConnection({
705
+ initialAudioMid: "1",
706
+ answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:3\r\na=sendrecv\r\n",
707
+ });
708
+ pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
709
+ const session = {
710
+ sessionId: "alice|bob",
711
+ callerEns: "alice.secnum.global",
712
+ toIdentity: "bob.secnum.global",
713
+ peerConnection: pc,
714
+ localAudioTrack: localTrack,
715
+ };
716
+ const neg = new WebRtcNegotiation({
717
+ id: "alice",
718
+ endpoint: "alice.secnum.global",
719
+ session,
720
+ signaling,
721
+ primitives: deepLifecyclePrimitives(),
722
+ logger: silentLogger,
723
+ });
724
+
725
+ const offerMid2 =
726
+ "v=0\r\n" +
727
+ "a=group:BUNDLE 0 2\r\n" +
728
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
729
+ "a=mid:0\r\n" +
730
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
731
+ "a=mid:2\r\n" +
732
+ "a=sendrecv\r\n";
733
+ const offerMid3 =
734
+ "v=0\r\n" +
735
+ "a=group:BUNDLE 0 3\r\n" +
736
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
737
+ "a=mid:0\r\n" +
738
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
739
+ "a=mid:3\r\n" +
740
+ "a=sendrecv\r\n";
741
+
742
+ await assert.doesNotReject(
743
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offerMid2 } }),
744
+ "first reuse cycle should recover from mid=2 mismatch",
745
+ );
746
+ await assert.doesNotReject(
747
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offerMid3 } }),
748
+ "second reuse cycle should recover from mid=3 mismatch",
749
+ );
750
+ });
751
+
752
+ test("DEEP MATRIX: createAnswer recovery handles missing mids 1/2/3", async () => {
753
+ for (const mid of ["1", "2", "3"]) {
754
+ const signaling = new FakeSignaling();
755
+ const session = {
756
+ sessionId: `alice|bob|create|${mid}`,
757
+ callerEns: "alice.secnum.global",
758
+ toIdentity: "bob.secnum.global",
759
+ peerConnection: new MidLifecyclePeerConnection({
760
+ throwOn: "createAnswer",
761
+ audioMid: "9",
762
+ answerSdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:${mid}\r\na=sendrecv\r\n`,
763
+ }),
764
+ localAudioTrack: null,
765
+ };
766
+ const neg = new WebRtcNegotiation({
767
+ id: "alice",
768
+ endpoint: "alice.secnum.global",
769
+ session,
770
+ signaling,
771
+ primitives: deepLifecyclePrimitives(),
772
+ logger: silentLogger,
773
+ });
774
+ const offer =
775
+ "v=0\r\n" +
776
+ `a=group:BUNDLE 0 ${mid}\r\n` +
777
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
778
+ "a=mid:0\r\n" +
779
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
780
+ `a=mid:${mid}\r\n` +
781
+ "a=sendrecv\r\n";
782
+ await assert.doesNotReject(
783
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offer } }),
784
+ `createAnswer recovery should handle missing mid=${mid}`,
785
+ );
786
+ }
787
+ });
788
+
789
+ test("DEEP MATRIX: setLocalDescription recovery handles missing mids 1/2/3", async () => {
790
+ for (const mid of ["1", "2", "3"]) {
791
+ const signaling = new FakeSignaling();
792
+ const session = {
793
+ sessionId: `alice|bob|setlocal|${mid}`,
794
+ callerEns: "alice.secnum.global",
795
+ toIdentity: "bob.secnum.global",
796
+ peerConnection: new MidLifecyclePeerConnection({
797
+ throwOn: "setLocalDescription",
798
+ audioMid: "9",
799
+ answerSdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:${mid}\r\na=sendrecv\r\n`,
800
+ }),
801
+ localAudioTrack: null,
802
+ };
803
+ const neg = new WebRtcNegotiation({
804
+ id: "alice",
805
+ endpoint: "alice.secnum.global",
806
+ session,
807
+ signaling,
808
+ primitives: deepLifecyclePrimitives(),
809
+ logger: silentLogger,
810
+ });
811
+ const offer =
812
+ "v=0\r\n" +
813
+ `a=group:BUNDLE 0 ${mid}\r\n` +
814
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
815
+ "a=mid:0\r\n" +
816
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
817
+ `a=mid:${mid}\r\n` +
818
+ "a=sendrecv\r\n";
819
+ await assert.doesNotReject(
820
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offer } }),
821
+ `setLocalDescription recovery should handle missing mid=${mid}`,
822
+ );
823
+ }
824
+ });
825
+
826
+ test("DEEP BUG REPRO: ice-restart after reuse survives stale mid map", async () => {
827
+ const signaling = new FakeSignaling();
828
+ const session = {
829
+ sessionId: "alice|bob|icerestart",
830
+ callerEns: "alice.secnum.global",
831
+ toIdentity: "bob.secnum.global",
832
+ peerConnection: new MidLifecyclePeerConnection({
833
+ throwOn: "setLocalDescription",
834
+ audioMid: "9",
835
+ answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:2\r\na=sendrecv\r\n",
836
+ }),
837
+ localAudioTrack: { kind: "audio" },
838
+ };
839
+ const neg = new WebRtcNegotiation({
840
+ id: "alice",
841
+ endpoint: "alice.secnum.global",
842
+ session,
843
+ signaling,
844
+ primitives: deepLifecyclePrimitives(),
845
+ logger: silentLogger,
846
+ });
847
+ const restartOffer =
848
+ "v=0\r\n" +
849
+ "a=group:BUNDLE 0 2\r\n" +
850
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
851
+ "a=mid:0\r\n" +
852
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
853
+ "a=mid:2\r\n" +
854
+ "a=sendrecv\r\n";
855
+ await assert.doesNotReject(
856
+ () => neg.applyOffer({ mode: "ice-restart", payload: { sdp: restartOffer } }),
857
+ "ice-restart path should recover from stale reused mid mappings",
858
+ );
859
+ });
860
+
861
+ test("DEEP BUG REPRO: applyOffer ignores stale ICE candidate m-line mismatch", async () => {
862
+ const signaling = new FakeSignaling();
863
+ const pc = new CandidateMLineStrictPeerConnection({
864
+ answerSdp: "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\n",
865
+ });
866
+ const session = {
867
+ sessionId: "alice|bob|mline",
868
+ callerEns: "alice.secnum.global",
869
+ toIdentity: "bob.secnum.global",
870
+ peerConnection: pc,
871
+ localAudioTrack: { kind: "audio" },
872
+ };
873
+ const neg = new WebRtcNegotiation({
874
+ id: "alice",
875
+ endpoint: "alice.secnum.global",
876
+ session,
877
+ signaling,
878
+ primitives: deepLifecyclePrimitives(),
879
+ logger: silentLogger,
880
+ });
881
+ const dataOnlyOffer =
882
+ "v=0\r\n" +
883
+ "a=group:BUNDLE 0\r\n" +
884
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
885
+ "a=mid:0\r\n";
886
+ await assert.doesNotReject(
887
+ () =>
888
+ neg.applyOffer({
889
+ mode: "ring",
890
+ payload: {
891
+ sdp: dataOnlyOffer,
892
+ candidates: [
893
+ { candidate: "candidate:1 1 udp 1 1.1.1.1 11111 typ host", sdpMid: "0", sdpMLineIndex: 0 },
894
+ { candidate: "candidate:2 1 udp 1 2.2.2.2 22222 typ host", sdpMid: "1", sdpMLineIndex: 1 },
895
+ ],
896
+ },
897
+ }),
898
+ "stale candidate with missing m-line must be ignored instead of failing offer ingress",
899
+ );
900
+ assert.equal(pc.appliedCandidates.length, 1, "valid candidate should still be applied");
901
+ assert.equal(pc.appliedCandidates[0].sdpMLineIndex, 0);
902
+ });
@@ -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
- try {
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
- 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
- }
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,44 @@ 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
+
257
272
  _isTrackAlreadyAddedError(err) {
258
273
  const msg = String(err?.message || "");
259
274
  return /Track already added/i.test(msg);
@@ -273,9 +288,11 @@ class WebRtcNegotiation extends CallNegotiationPort {
273
288
  try { transceiver.sender?.replaceTrack?.(track); } catch (_) {}
274
289
  }
275
290
 
276
- _repairAudioForOfferMid(sdp, pc) {
291
+ _repairAudioForOfferMid(sdp, pc, opts = {}) {
277
292
  this._ensureAudioReadyForOffer(sdp, pc, { force: true });
278
- 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;
279
296
  const track = this.session.localAudioTrack || null;
280
297
  const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
281
298
  for (const transceiver of audioTransceivers) this._primeAudioTransceiver(transceiver, track);
@@ -283,17 +300,21 @@ class WebRtcNegotiation extends CallNegotiationPort {
283
300
 
284
301
  const hasExactMid = () =>
285
302
  (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 (_) {}
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;
295
317
  }
296
- if (hasExactMid()) return;
297
318
 
298
319
  // Final fallback: try creating/binding a fresh audio transceiver if the
299
320
  // implementation supports it.
@@ -308,6 +329,16 @@ class WebRtcNegotiation extends CallNegotiationPort {
308
329
  }
309
330
  } catch (_) {}
310
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
+ }
311
342
  }
312
343
 
313
344
  _ensureAudioReadyForOffer(sdp, pc, opts = {}) {
@@ -93,10 +93,20 @@ function logSdp(sessionId, label, sdp, logger = console) {
93
93
 
94
94
  async function addIceCandidates(pc, candidates, RTCIceCandidate) {
95
95
  let added = 0;
96
+ const isMLineMismatch = (err) => {
97
+ const msg = String(err?.message || "");
98
+ return /m[-\s]?line not found/i.test(msg);
99
+ };
96
100
  for (const c of (candidates || [])) {
97
101
  if (c && c.candidate) {
98
- await pc.addIceCandidate(new RTCIceCandidate(c));
99
- added++;
102
+ try {
103
+ await pc.addIceCandidate(new RTCIceCandidate(c));
104
+ added++;
105
+ } catch (err) {
106
+ // Reused sessions can receive stale trickle candidates that refer to
107
+ // an m-line no longer present in the latest negotiated SDP.
108
+ if (!isMLineMismatch(err)) throw err;
109
+ }
100
110
  }
101
111
  }
102
112
  return added;