arnacon-webrtc-service 0.2.6 → 0.2.8

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.6",
3
+ "version": "0.2.8",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -235,6 +235,15 @@ class MidMapLagPeerConnection extends FakePeerConnection {
235
235
  }
236
236
  }
237
237
 
238
+ class MidMapLagTrackCreateFailsPeerConnection extends MidMapLagPeerConnection {
239
+ addTransceiver(kindOrTrack) {
240
+ if (kindOrTrack && typeof kindOrTrack === "object" && kindOrTrack.kind === "audio") {
241
+ throw new Error("Track already added");
242
+ }
243
+ return super.addTransceiver(kindOrTrack);
244
+ }
245
+ }
246
+
238
247
  class CandidateMLineStrictPeerConnection extends FakePeerConnection {
239
248
  constructor(opts = {}) {
240
249
  super(opts);
@@ -783,6 +792,43 @@ test("DEEP BUG REPRO: repeated decline/reuse can advance to mid=2 then mid=3", a
783
792
  );
784
793
  });
785
794
 
795
+ test("DEEP BUG REPRO: recovery falls back to addTransceiver('audio') when addTransceiver(track) fails", async () => {
796
+ const signaling = new FakeSignaling();
797
+ const localTrack = { kind: "audio" };
798
+ const pc = new MidMapLagTrackCreateFailsPeerConnection({
799
+ initialAudioMid: "1",
800
+ answerSdp: "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 8\r\na=mid:2\r\na=sendrecv\r\n",
801
+ });
802
+ pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
803
+ const session = {
804
+ sessionId: "alice|bob|mid2-fallback",
805
+ callerEns: "alice.secnum.global",
806
+ toIdentity: "bob.secnum.global",
807
+ peerConnection: pc,
808
+ localAudioTrack: localTrack,
809
+ };
810
+ const neg = new WebRtcNegotiation({
811
+ id: "alice",
812
+ endpoint: "alice.secnum.global",
813
+ session,
814
+ signaling,
815
+ primitives: deepLifecyclePrimitives(),
816
+ logger: silentLogger,
817
+ });
818
+ const offerWithAudioMid2 =
819
+ "v=0\r\n" +
820
+ "a=group:BUNDLE 0 2\r\n" +
821
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
822
+ "a=mid:0\r\n" +
823
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
824
+ "a=mid:2\r\n" +
825
+ "a=sendrecv\r\n";
826
+ await assert.doesNotReject(
827
+ () => neg.applyOffer({ mode: "ring", payload: { sdp: offerWithAudioMid2 } }),
828
+ "mid recovery should succeed even if addTransceiver(track) fails on reused track",
829
+ );
830
+ });
831
+
786
832
  test("DEEP MATRIX: createAnswer recovery handles missing mids 1/2/3", async () => {
787
833
  for (const mid of ["1", "2", "3"]) {
788
834
  const signaling = new FakeSignaling();
@@ -84,6 +84,37 @@ const inactiveEndCallOfferSdp =
84
84
  "a=rtcp-mux\r\n" +
85
85
  "a=rtpmap:8 PCMA/8000\r\n";
86
86
 
87
+ const activeAudioOfferMid2Sdp =
88
+ "v=0\r\n" +
89
+ "o=- 83392955 0 IN IP4 0.0.0.0\r\n" +
90
+ "s=-\r\n" +
91
+ "t=0 0\r\n" +
92
+ "a=group:BUNDLE 0 2\r\n" +
93
+ "a=extmap-allow-mixed\r\n" +
94
+ "a=msid-semantic:WMS *\r\n" +
95
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
96
+ "c=IN IP4 0.0.0.0\r\n" +
97
+ "a=ice-ufrag:ddcb\r\n" +
98
+ "a=ice-pwd:701e83d2d6de10d14b0eea\r\n" +
99
+ "a=ice-options:trickle\r\n" +
100
+ "a=fingerprint:sha-256 F8:C4:0C:11:3F:96:C8:6A:48:A6:88:42:28:86:2B:9D:77:11:E2:75:65:DE:81:F5:7E:CA:B5:AB:0A:6B:64:F1\r\n" +
101
+ "a=setup:passive\r\n" +
102
+ "a=mid:0\r\n" +
103
+ "a=sctp-port:5000\r\n" +
104
+ "a=max-message-size:65536\r\n" +
105
+ "m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
106
+ "c=IN IP4 0.0.0.0\r\n" +
107
+ "a=ice-ufrag:ddcb\r\n" +
108
+ "a=ice-pwd:701e83d2d6de10d14b0eea\r\n" +
109
+ "a=ice-options:trickle\r\n" +
110
+ "a=fingerprint:sha-256 F8:C4:0C:11:3F:96:C8:6A:48:A6:88:42:28:86:2B:9D:77:11:E2:75:65:DE:81:F5:7E:CA:B5:AB:0A:6B:64:F1\r\n" +
111
+ "a=setup:passive\r\n" +
112
+ "a=sendrecv\r\n" +
113
+ "a=mid:2\r\n" +
114
+ "a=rtcp:9 IN IP4 0.0.0.0\r\n" +
115
+ "a=rtcp-mux\r\n" +
116
+ "a=rtpmap:8 PCMA/8000\r\n";
117
+
87
118
  const malformedAudioOfferSdp =
88
119
  "v=0\r\n" +
89
120
  "o=- 41053953 0 IN IP4 0.0.0.0\r\n" +
@@ -196,3 +227,29 @@ test("WERIFT REPLAY: ackEnd path then redial on same session", async () => {
196
227
  await harness.dispose();
197
228
  }
198
229
  });
230
+
231
+ test("WERIFT REPLAY: decline/end then redial with audio mid=2 stays reusable", async () => {
232
+ const harness = buildWeriftNegotiationHarness({ withAudioTrack: true, withDataChannel: true });
233
+ try {
234
+ await assert.doesNotReject(() =>
235
+ harness.neg.applyOffer({
236
+ mode: "ring",
237
+ payload: { sdp: activeAudioOfferSdp, candidates: [candidateM0] },
238
+ }),
239
+ );
240
+ await assert.doesNotReject(() =>
241
+ harness.neg.endCall({
242
+ mode: "remote",
243
+ payload: { type: "offer", sdp: inactiveEndCallOfferSdp },
244
+ }),
245
+ );
246
+ await assert.doesNotReject(() =>
247
+ harness.neg.applyOffer({
248
+ mode: "ring",
249
+ payload: { sdp: activeAudioOfferMid2Sdp, candidates: [candidateM0] },
250
+ }),
251
+ );
252
+ } finally {
253
+ await harness.dispose();
254
+ }
255
+ });
@@ -1129,6 +1129,17 @@ function onPeerConnected(sessionId) {
1129
1129
  // No pending-bridge bookkeeping anymore — pairing is handled by the registry.
1130
1130
  }
1131
1131
 
1132
+ function isRecoverableOfferIngressError(err) {
1133
+ const msg = String(err?.message || "");
1134
+ return (
1135
+ /Transceiver with mid=\d+ not found/i.test(msg) ||
1136
+ /m[-\s]?line not found/i.test(msg) ||
1137
+ /iceParams/i.test(msg) ||
1138
+ /media section/i.test(msg) ||
1139
+ /reading 'kind'/i.test(msg)
1140
+ );
1141
+ }
1142
+
1132
1143
  /**
1133
1144
  * Data channel ingress -> normalized leg events for the owning PolySession.
1134
1145
  * Replaces SignalingMessageRouter phase-gating (leg states gate instead).
@@ -1208,6 +1219,27 @@ function onDataChannelMessage(sessionId, rawMessage, meta = {}) {
1208
1219
  if (!event) return;
1209
1220
  const ref = polyWebrtcRef(poly, session, channelRole);
1210
1221
  poly.onIngress(ref, event).catch((err) => {
1222
+ if (action === "offer" && isRecoverableOfferIngressError(err) && !payload.__polyIngressRetriedOnce) {
1223
+ payload.__polyIngressRetriedOnce = true;
1224
+ // Keep the current poly/session/PC alive and retry ingress in place.
1225
+ // This mirrors client-side reuse semantics after decline/end flows.
1226
+ const latestSession = sessions.get(sessionId);
1227
+ const latestPoly = latestSession ? polyForSession(latestSession) : null;
1228
+ if (!latestSession || !latestPoly) {
1229
+ console.error(`[${sessionId}] poly ingress (${action}) retry skipped: no active session/poly`);
1230
+ return;
1231
+ }
1232
+ const retryRef = polyWebrtcRef(latestPoly, latestSession, channelRole);
1233
+ const retryEvent = polyIngress.toLegEvent(action, payload, { channelRole });
1234
+ if (!retryEvent) {
1235
+ console.error(`[${sessionId}] poly ingress (${action}) retry skipped: no retry event`);
1236
+ return;
1237
+ }
1238
+ latestPoly.onIngress(retryRef, retryEvent).catch((retryErr) => {
1239
+ console.error(`[${sessionId}] poly ingress (${action}) retry failed: ${retryErr.message}`);
1240
+ });
1241
+ return;
1242
+ }
1211
1243
  console.error(`[${sessionId}] poly ingress (${action}) failed: ${err.message}`);
1212
1244
  });
1213
1245
  }