arnacon-webrtc-service 0.2.8 → 0.2.10

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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/services/secnum.json +5 -2
  3. package/webRTCservice/modules/calls/inbound/InboundCallFlow.js +3 -10
  4. package/webRTCservice/modules/calls/poly/PolyIngress.js +3 -0
  5. package/webRTCservice/modules/calls/poly/PolySession.js +62 -2
  6. package/webRTCservice/modules/calls/poly/PolySessionRegistry.js +24 -17
  7. package/webRTCservice/modules/calls/poly/ReconcileRules.js +7 -0
  8. package/webRTCservice/modules/calls/poly/SessionLeg.js +7 -0
  9. package/webRTCservice/modules/calls/poly/__tests__/PolyIngress.test.js +6 -0
  10. package/webRTCservice/modules/calls/poly/__tests__/PolySession.test.js +155 -4
  11. package/webRTCservice/modules/calls/poly/__tests__/PolySessionRegistry.test.js +72 -0
  12. package/webRTCservice/modules/calls/poly/__tests__/ReconcileRules.test.js +10 -0
  13. package/webRTCservice/modules/calls/poly/__tests__/Scenarios.test.js +331 -0
  14. package/webRTCservice/modules/calls/poly/__tests__/SessionLeg.test.js +8 -0
  15. package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.test.js +10 -0
  16. package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.werift.integration.test.js +13 -0
  17. package/webRTCservice/modules/calls/poly/__tests__/helpers/scenarioHelpers.js +9 -4
  18. package/webRTCservice/modules/calls/poly/createPolyCore.js +14 -2
  19. package/webRTCservice/modules/calls/poly/negotiation/WebRtcNegotiation.js +3 -10
  20. package/webRTCservice/modules/calls/runtime/SessionResources.js +9 -0
  21. package/webRTCservice/modules/calls/webrtc/WebRtcOfferUseCase.js +83 -39
  22. package/webRTCservice/modules/calls/webrtc/WebRtcOutboundLegFactory.js +9 -1
  23. package/webRTCservice/modules/gateways/sip/SipClient.js +54 -0
  24. package/webRTCservice/modules/media/legs/SipLeg.js +7 -0
  25. package/webRTCservice/modules/media/legs/WebRtcClientLeg.js +48 -4
  26. package/webRTCservice/modules/media/werift/PeerConnectionFactory.js +8 -1
  27. package/webRTCservice/modules/media/werift/SsrcRouterPatch.js +27 -23
  28. package/webRTCservice/modules/participants/signaling/SignalingEnvelope.js +3 -10
  29. package/webRTCservice/modules/participants/signaling/SignalingPipeline.js +34 -47
  30. package/webRTCservice/modules/runtime/CallPairRef.js +46 -0
  31. package/webRTCservice/modules/runtime/CallPairResolver.js +76 -0
  32. package/webRTCservice/modules/runtime/SessionStore.js +11 -11
  33. package/webRTCservice/modules/server/HttpServer.js +14 -3
  34. package/webRTCservice/webRTCmanager.js +561 -63
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arnacon-webrtc-service",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -3,7 +3,7 @@
3
3
  "providerId": "secnum",
4
4
  "static": {
5
5
  "status": "active",
6
- "domains": ["secnumtest.global", "secnum.global", "cellactm.global", "cellactl.global"],
6
+ "domains": ["secnumtest.global", "secnum.global"],
7
7
  "trustedIngressIps": ["51.85.90.127"],
8
8
  "outboundSbcAddress": "185.62.121.10",
9
9
  "minuteLimitSeconds": 600
@@ -13,7 +13,10 @@
13
13
  "providerId": "secnum",
14
14
  "static": {
15
15
  "status": "active",
16
- "domains": ["secnum.global", "secnumtest.global", "cellactm.global", "cellactl.global"],
16
+ "domains": [
17
+ "secnumtest.global",
18
+ "secnum.global"
19
+ ],
17
20
  "trustedIngressIps": ["51.85.90.127"],
18
21
  "outboundSbcAddress": "185.62.121.10",
19
22
  "minuteLimitSeconds": 600
@@ -1,14 +1,7 @@
1
1
  const { buildOfferPayload, serializeNotifyPayload } = require("../../participants/signaling/SignalingEnvelope");
2
-
3
- function identityLabel(identity) {
4
- if (!identity || typeof identity !== "string") return identity;
5
- const trimmed = identity.trim();
6
- const atPos = trimmed.indexOf("@");
7
- if (atPos > 0) return trimmed.slice(0, atPos);
8
- const dotPos = trimmed.indexOf(".");
9
- if (dotPos > 0) return trimmed.slice(0, dotPos);
10
- return trimmed;
11
- }
2
+ const {
3
+ identityLabel,
4
+ } = require("../../runtime/CallPairRef");
12
5
 
13
6
  function createInboundCallFlow({
14
7
  createSession,
@@ -36,6 +36,9 @@ class PolyIngress {
36
36
  // (payload.type for signaling, msg.action for call messages, or "bye").
37
37
  toLegEvent(action, payload = {}, meta = {}) {
38
38
  if (action === "offer") {
39
+ if (meta.forceOffer === true) {
40
+ return makeLegEvent(LEG_EVENTS.OFFER, payload, meta);
41
+ }
39
42
  const type = isInactiveOffer(payload.sdp) ? LEG_EVENTS.END_RENEGOTIATION : LEG_EVENTS.OFFER;
40
43
  return makeLegEvent(type, payload, meta);
41
44
  }
@@ -8,6 +8,7 @@
8
8
  const { reconcile } = require("./ReconcileRules");
9
9
  const { LEG_INTENTS } = require("./ports");
10
10
  const { LEG_STATES, isActiveCall } = require("./states");
11
+ const { identityLabel } = require("../../runtime/CallPairRef");
11
12
 
12
13
  const MAX_RECONCILE_PASSES = 50;
13
14
 
@@ -22,6 +23,11 @@ const FAIL_ON_ERROR_INTENTS = new Set([
22
23
  LEG_INTENTS.ACK_CONNECTED,
23
24
  LEG_INTENTS.ACK_RING,
24
25
  ]);
26
+ const RETRY_ON_NO_OPEN_DC_INTENTS = new Set([
27
+ LEG_INTENTS.RING,
28
+ LEG_INTENTS.ACK_CONNECTED,
29
+ LEG_INTENTS.ACK_RING,
30
+ ]);
25
31
 
26
32
  class PolySession {
27
33
  constructor({ id, legA, legB, mediaController, rules = reconcile, teardownHooks = [], logger = console } = {}) {
@@ -35,6 +41,16 @@ class PolySession {
35
41
  // billing). Kept as a list so callers compose without subclassing.
36
42
  this._teardownHooks = teardownHooks.filter((h) => typeof h === "function");
37
43
  this._teardownRan = false;
44
+ // Call-lifecycle hooks, fired on the active<->inactive edge derived from leg
45
+ // states (a call is "active" while either leg isActiveCall). This is the
46
+ // single robust seam for per-call side-effects that must bracket the call
47
+ // itself -- NOT the poly's disposal -- so they fire once per call on EVERY
48
+ // end path (hangup either side, mid-call failure, transport drop, reject)
49
+ // and again on a reused poly's next call. Used for minute-counter
50
+ // start/finish so billing always stops regardless of how the call ends.
51
+ this._onCallStart = null;
52
+ this._onCallEnd = null;
53
+ this._callActive = false;
38
54
  this.logger = logger;
39
55
 
40
56
  this.mediaHandle = null;
@@ -56,8 +72,14 @@ class PolySession {
56
72
  }
57
73
 
58
74
  legByEndpoint(endpoint) {
59
- if (this.legs.a.endpoint === endpoint || this.legs.a.id === endpoint) return this.legs.a;
60
- if (this.legs.b.endpoint === endpoint || this.legs.b.id === endpoint) return this.legs.b;
75
+ const wanted = identityLabel(String(endpoint || "").toLowerCase());
76
+ if (!wanted) return null;
77
+ const aEndpoint = identityLabel(String(this.legs.a.endpoint || "").toLowerCase());
78
+ const aId = identityLabel(String(this.legs.a.id || "").toLowerCase());
79
+ if (aEndpoint === wanted || aId === wanted) return this.legs.a;
80
+ const bEndpoint = identityLabel(String(this.legs.b.endpoint || "").toLowerCase());
81
+ const bId = identityLabel(String(this.legs.b.id || "").toLowerCase());
82
+ if (bEndpoint === wanted || bId === wanted) return this.legs.b;
61
83
  return null;
62
84
  }
63
85
 
@@ -110,11 +132,38 @@ class PolySession {
110
132
  }
111
133
  this._recoverFailedLegs();
112
134
  }
135
+ await this._settleCallActivity();
113
136
  } finally {
114
137
  this._running = false;
115
138
  }
116
139
  }
117
140
 
141
+ // Detect the in-call edge from the quiesced leg states and fire the lifecycle
142
+ // hooks once per transition. "In call" = BOTH legs are IN_CALL, i.e. the actual
143
+ // answered conversation (the media-bridged window) -- ring/setup/teardown are
144
+ // deliberately excluded. The falling edge is the reliable "conversation is over"
145
+ // signal that fires on every end path (hangup either side, mid-call failure,
146
+ // transport drop) while the poly itself survives for reuse.
147
+ async _settleCallActivity() {
148
+ const active = this.legs.a.state === LEG_STATES.IN_CALL && this.legs.b.state === LEG_STATES.IN_CALL;
149
+ if (active === this._callActive) return;
150
+ this._callActive = active;
151
+ const hook = active ? this._onCallStart : this._onCallEnd;
152
+ if (typeof hook !== "function") return;
153
+ try {
154
+ await hook(this);
155
+ } catch (err) {
156
+ this.logger.error(`[${this.id}] call-${active ? "start" : "end"} hook failed: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ // Inject per-call lifecycle side-effects (e.g. minuteCounter start/finish).
161
+ // Idempotent to set; safe to (re)assign on a reused poly before its next call.
162
+ setCallActivityHooks({ onCallStart = null, onCallEnd = null } = {}) {
163
+ if (onCallStart !== undefined) this._onCallStart = typeof onCallStart === "function" ? onCallStart : null;
164
+ if (onCallEnd !== undefined) this._onCallEnd = typeof onCallEnd === "function" ? onCallEnd : null;
165
+ }
166
+
118
167
  async _execute(action) {
119
168
  if (action.kind === "media") {
120
169
  return this._executeMedia(action);
@@ -143,6 +192,17 @@ class PolySession {
143
192
  }
144
193
  } catch (err) {
145
194
  this.logger.error(`[${this.id}] intent ${action.intent} on leg ${leg.id} failed: ${err.message}`);
195
+ // Dead/missing data channel on forward signaling is recoverable transport
196
+ // loss: drop this leg to DISCONNECTED so reconcile drives CONNECT -> RING.
197
+ if (err?.code === "NO_OPEN_DC" && RETRY_ON_NO_OPEN_DC_INTENTS.has(action.intent)) {
198
+ if (leg.state !== LEG_STATES.DISCONNECTED) {
199
+ leg.setState(LEG_STATES.DISCONNECTED, {
200
+ reason: `intent-no-open-dc:${action.intent}`,
201
+ from: "self",
202
+ });
203
+ }
204
+ return;
205
+ }
146
206
  // A reaching intent failed -> the leg cannot carry the call. Fail it so
147
207
  // reconcile drives the peer into an end-call reneg (a FAILED leg is a
148
208
  // teardown trigger). Idempotent: no-op if already FAILED/torn down.
@@ -4,22 +4,13 @@
4
4
  // Replaces the old linkedSessionId / bridgedWith / pendingBridges pairing.
5
5
 
6
6
  const { PolySession } = require("./PolySession");
7
-
8
- // Bare label of an identity: "972...@x" / "972....global" / "972..." -> "972...".
9
- function identityLabel(identity) {
10
- if (!identity || typeof identity !== "string") return identity;
11
- const trimmed = identity.trim();
12
- const atPos = trimmed.indexOf("@");
13
- if (atPos > 0) return trimmed.slice(0, atPos);
14
- const dotPos = trimmed.indexOf(".");
15
- if (dotPos > 0) return trimmed.slice(0, dotPos);
16
- return trimmed;
17
- }
7
+ const {
8
+ identityLabel,
9
+ pairKeyFromIdentities,
10
+ } = require("../../runtime/CallPairRef");
18
11
 
19
12
  function pairKey(a, b) {
20
- return [identityLabel(String(a || "").toLowerCase()), identityLabel(String(b || "").toLowerCase())]
21
- .sort()
22
- .join("|");
13
+ return pairKeyFromIdentities(a, b);
23
14
  }
24
15
 
25
16
  class PolySessionRegistry {
@@ -46,8 +37,21 @@ class PolySessionRegistry {
46
37
  return key ? (this.byKey.get(key) || null) : null;
47
38
  }
48
39
 
40
+ // All PolySessions that currently include this endpoint.
41
+ listByEndpoint(endpoint) {
42
+ if (!endpoint) return [];
43
+ const wanted = identityLabel(String(endpoint).toLowerCase());
44
+ if (!wanted) return [];
45
+ const matches = [];
46
+ for (const poly of this.byKey.values()) {
47
+ if (!poly) continue;
48
+ if (poly.legByEndpoint(wanted)) matches.push(poly);
49
+ }
50
+ return matches;
51
+ }
52
+
49
53
  keyForPair(a, b) {
50
- return pairKey(a, b);
54
+ return pairKeyFromIdentities(a, b);
51
55
  }
52
56
 
53
57
  // Resolve (or create) the PolySession for a pair and return which leg the
@@ -56,13 +60,16 @@ class PolySessionRegistry {
56
60
  if (!a?.endpoint || !b?.endpoint) {
57
61
  throw new Error("PolySessionRegistry.resolve requires both leg endpoints");
58
62
  }
59
- const key = pairKey(a.endpoint, b.endpoint);
63
+ const key = pairKeyFromIdentities(a.endpoint, b.endpoint);
60
64
  let poly = this.byKey.get(key);
61
65
  if (!poly) {
62
66
  poly = this._create(key, a, b);
63
67
  }
64
68
  const targetEndpoint = target === "b" ? b.endpoint : target === "a" ? a.endpoint : (target || a.endpoint);
65
- const leg = poly.legByEndpoint(targetEndpoint) || poly.legs.a;
69
+ const leg = poly.legByEndpoint(targetEndpoint);
70
+ if (!leg) {
71
+ throw new Error(`PolySessionRegistry.resolve target endpoint not found in pair ${key}: ${targetEndpoint}`);
72
+ }
66
73
  return { poly, leg, ref: poly.refOf(leg), key };
67
74
  }
68
75
 
@@ -73,6 +73,13 @@ function reconcile(snapshot, event = null) {
73
73
  if (a.state === LEG_STATES.RINGING && isCaller(b.state)) actions.push(intent("b", LEG_INTENTS.ACK_RING, "a"));
74
74
  if (b.state === LEG_STATES.RINGING && isCaller(a.state)) actions.push(intent("a", LEG_INTENTS.ACK_RING, "b"));
75
75
  }
76
+ // Glare guard: two fresh caller legs can coexist briefly. Keep this strictly
77
+ // user-driven (no auto-answer) and deterministically present one side so
78
+ // the state machine does not stall in CALLING+CALLING.
79
+ if (a.state === LEG_STATES.CALLING && b.state === LEG_STATES.CALLING) {
80
+ actions.push(intent("b", LEG_INTENTS.RING, "a"));
81
+ return actions;
82
+ }
76
83
 
77
84
  // One side reached in-call while the peer is still ringing/calling -> they
78
85
  // picked up: finalize the peer (flush its held answer) and bridge media.
@@ -210,6 +210,13 @@ class SessionLeg {
210
210
  await this._tx(() => this.negotiation.applyOffer?.({ leg: this, mode: "ice-restart", ...event }));
211
211
  return;
212
212
  }
213
+ // Glare guard: if this leg is already being presented as the callee,
214
+ // accept the fresh SDP but keep RINGING so reconcile can still
215
+ // progress toward a user-driven answer.
216
+ if (this.state === LEG_STATES.RINGING) {
217
+ await this._tx(() => this.negotiation.applyOffer?.({ leg: this, mode: "ring", ...event }));
218
+ return;
219
+ }
213
220
  // Otherwise this endpoint wants to start a call.
214
221
  await this._tx(() => this.negotiation.applyOffer?.({ leg: this, mode: "ring", ...event }));
215
222
  this.setState(LEG_STATES.CALLING, { reason: "client-offer", from: "self", payload: event.payload });
@@ -37,6 +37,12 @@ test("late inactive offer maps to END_RENEGOTIATION (teardown, not ring)", () =>
37
37
  assert.equal(ev.type, LEG_EVENTS.END_RENEGOTIATION);
38
38
  });
39
39
 
40
+ test("forced offer meta keeps datachannel-only offer as OFFER", () => {
41
+ const { ingress } = build();
42
+ const ev = ingress.toLegEvent("offer", { sdp: "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" }, { forceOffer: true });
43
+ assert.equal(ev.type, LEG_EVENTS.OFFER);
44
+ });
45
+
40
46
  test("call actions map to their leg events", () => {
41
47
  const { ingress } = build();
42
48
  assert.equal(ingress.toLegEvent("answer", {}).type, LEG_EVENTS.ANSWER);
@@ -5,6 +5,8 @@ const { PolySession } = require("../PolySession");
5
5
  const { LEG_STATES: S } = require("../states");
6
6
  const { LEG_EVENTS, makeLegEvent } = require("../ports");
7
7
  const { FakeMediaController, makeWebRtcLeg, makeSipLeg, silentLogger } = require("./fakes");
8
+ const { MediaBridge } = require("../../../media/MediaBridge");
9
+ const { MediaLeg } = require("../../../media/MediaLeg");
8
10
 
9
11
  function buildPoly(legAInfo, legBInfo) {
10
12
  const media = new FakeMediaController();
@@ -218,6 +220,26 @@ test("sip ring failure ends the webrtc caller, then the sip leg recovers for reu
218
220
  assert.equal(media.connects.length, 1, "media bridged on the reused call");
219
221
  });
220
222
 
223
+ test("no-open-dc during ring marks callee disconnected and reconnects instead of ending caller", async () => {
224
+ const a = makeWebRtcLeg("alice");
225
+ const b = makeWebRtcLeg("bob", { deferConnect: true });
226
+ const { poly } = buildPoly(a, b);
227
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
228
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
229
+
230
+ b.negotiation.ring = async () => {
231
+ const err = new Error("no open data channel for offer");
232
+ err.code = "NO_OPEN_DC";
233
+ throw err;
234
+ };
235
+
236
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
237
+ assert.equal(a.leg.state, S.CALLING, "caller must remain alive for retry");
238
+ assert.equal(b.leg.state, S.CONNECTING, "callee should reconnect after no-open-dc");
239
+ assert.equal(a.negotiation.named("endCall").length, 0, "no forced teardown on caller");
240
+ assert.equal(b.negotiation.named("connect").length, 1, "reconnect should be triggered");
241
+ });
242
+
221
243
  test("reject before answer: caller is ended, no media ever bridged", async () => {
222
244
  const a = makeWebRtcLeg("alice");
223
245
  const b = makeWebRtcLeg("bob");
@@ -253,6 +275,26 @@ test("teardown hooks (e.g. minuteCounter.finish) run exactly once on dispose", a
253
275
  assert.deepEqual(reasons, ["call-ended"]);
254
276
  });
255
277
 
278
+ test("call activity hooks fire once on call start/end edges", async () => {
279
+ const a = makeWebRtcLeg("alice");
280
+ const b = makeWebRtcLeg("bob");
281
+ const { poly } = buildPoly(a, b);
282
+ const activity = [];
283
+ poly.setCallActivityHooks({
284
+ onCallStart: () => activity.push("start"),
285
+ onCallEnd: () => activity.push("end"),
286
+ });
287
+
288
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
289
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
290
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
291
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "ans" }));
292
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.END));
293
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.END_RENEGOTIATION, { type: "answer", sdp: "end-ans" }));
294
+
295
+ assert.deepEqual(activity, ["start", "end"]);
296
+ });
297
+
256
298
  test("second call reuse: after end, a fresh offer rings the peer again", async () => {
257
299
  const a = makeWebRtcLeg("alice");
258
300
  const b = makeWebRtcLeg("bob");
@@ -314,7 +356,7 @@ test("caller transport closes during callee connect: no media, no duplicate tear
314
356
  assert.equal(b.negotiation.named("endCall").length, 0);
315
357
  });
316
358
 
317
- test("simultaneous offers (glare) converges to a single connected call graph", async () => {
359
+ test("simultaneous offers (glare) remains ring-driven until explicit answer", async () => {
318
360
  const a = makeWebRtcLeg("alice");
319
361
  const b = makeWebRtcLeg("bob");
320
362
  const { poly, media } = buildPoly(a, b);
@@ -324,13 +366,14 @@ test("simultaneous offers (glare) converges to a single connected call graph", a
324
366
  await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o-a" }));
325
367
  await poly.onIngress("b", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o-b" }));
326
368
  assert.equal(a.leg.state, S.CALLING);
327
- assert.equal(b.leg.state, S.CALLING);
369
+ assert.equal(b.leg.state, S.RINGING);
370
+ assert.equal(media.connects.length, 0, "glare must not auto-bridge media");
371
+ assert.equal(media.disconnects.length, 0);
328
372
 
329
373
  await poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "ans-b" }));
330
374
  assert.equal(a.leg.state, S.IN_CALL);
331
375
  assert.equal(b.leg.state, S.IN_CALL);
332
- assert.equal(media.connects.length, 1, "glare must not duplicate media bridge");
333
- assert.equal(media.disconnects.length, 0);
376
+ assert.equal(media.connects.length, 1, "media bridges only after explicit answer");
334
377
  });
335
378
 
336
379
  test("repeated settle passes do not duplicate media disconnect", async () => {
@@ -349,3 +392,111 @@ test("repeated settle passes do not duplicate media disconnect", async () => {
349
392
  await poly._settle();
350
393
  assert.equal(media.disconnects.length, 1, "teardown reconcile remains idempotent");
351
394
  });
395
+
396
+ test("shared endpoint across AB and AC: transport close in AB does not impact AC", async () => {
397
+ const abA = makeWebRtcLeg("alice");
398
+ const abB = makeWebRtcLeg("bob");
399
+ const acA = makeWebRtcLeg("alice");
400
+ const acC = makeWebRtcLeg("carol");
401
+ const first = buildPoly(abA, abB);
402
+ const second = buildPoly(acA, acC);
403
+
404
+ await first.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
405
+ await first.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
406
+ await second.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
407
+ await second.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
408
+ await first.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-ab" }));
409
+ await first.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-ab" }));
410
+ await second.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-ac" }));
411
+ await second.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-ac" }));
412
+
413
+ assert.equal(abA.leg.state, S.IN_CALL);
414
+ assert.equal(abB.leg.state, S.IN_CALL);
415
+ assert.equal(acA.leg.state, S.IN_CALL);
416
+ assert.equal(acC.leg.state, S.IN_CALL);
417
+
418
+ await first.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_CLOSE));
419
+ assert.equal(abB.leg.state, S.FAILED);
420
+ assert.equal(abA.leg.state, S.ENDING);
421
+ assert.equal(acA.leg.state, S.IN_CALL, "AB transport close must not affect AC");
422
+ assert.equal(acC.leg.state, S.IN_CALL, "AB transport close must not affect AC");
423
+ assert.equal(first.media.disconnects.length, 1);
424
+ assert.equal(second.media.disconnects.length, 0);
425
+ });
426
+
427
+ class FakeRtpLeg extends MediaLeg {
428
+ constructor({ id, kind, payloadType = 8 } = {}) {
429
+ super({ id, kind, payloadType, logger: silentLogger });
430
+ this._handler = null;
431
+ this.writtenPackets = 0;
432
+ }
433
+
434
+ onRtp(handler) {
435
+ this._handler = handler;
436
+ const dispose = () => { this._handler = null; };
437
+ this.addDisposer(dispose);
438
+ return dispose;
439
+ }
440
+
441
+ writeRtp() {
442
+ this.writtenPackets += 1;
443
+ this.noteOutbound();
444
+ }
445
+
446
+ emitRtp(packet) {
447
+ if (!this._handler) return;
448
+ this.noteInbound();
449
+ this._handler(packet);
450
+ }
451
+ }
452
+
453
+ function fakeRtpPacket(ssrc, seq) {
454
+ return {
455
+ header: {
456
+ ssrc,
457
+ sequenceNumber: seq,
458
+ timestamp: seq * 160,
459
+ payloadType: 8,
460
+ },
461
+ };
462
+ }
463
+
464
+ test("media bridge passes RTP both directions", async () => {
465
+ const a = new FakeRtpLeg({ id: "webrtc:a", kind: "webrtc" });
466
+ const b = new FakeRtpLeg({ id: "sip:b", kind: "sip" });
467
+ const bridge = new MediaBridge({ sessionId: "webrtc<->sip", a, b, logger: silentLogger, statsIntervalMs: 10000 });
468
+ await bridge.start();
469
+
470
+ for (let i = 0; i < 5; i += 1) a.emitRtp(fakeRtpPacket(1111, i + 1));
471
+ for (let i = 0; i < 4; i += 1) b.emitRtp(fakeRtpPacket(2222, i + 1));
472
+
473
+ const health = bridge.health();
474
+ assert.equal(health.aToB, 5);
475
+ assert.equal(health.bToA, 4);
476
+ assert.equal(a.stats.inboundPackets > 0, true);
477
+ assert.equal(b.stats.inboundPackets > 0, true);
478
+ await bridge.stop();
479
+ });
480
+
481
+ test("media bridge still bidirectional after teardown and reconnect", async () => {
482
+ const a1 = new FakeRtpLeg({ id: "webrtc:reuse-a", kind: "webrtc" });
483
+ const b1 = new FakeRtpLeg({ id: "sip:reuse-b", kind: "sip" });
484
+ const first = new MediaBridge({ sessionId: "reuse-call-1", a: a1, b: b1, logger: silentLogger, statsIntervalMs: 10000 });
485
+ await first.start();
486
+ a1.emitRtp(fakeRtpPacket(3333, 1));
487
+ b1.emitRtp(fakeRtpPacket(4444, 1));
488
+ await first.stop();
489
+ assert.equal(first.health().aToB, 1);
490
+ assert.equal(first.health().bToA, 1);
491
+
492
+ const a2 = new FakeRtpLeg({ id: "webrtc:reuse-a", kind: "webrtc" });
493
+ const b2 = new FakeRtpLeg({ id: "sip:reuse-b", kind: "sip" });
494
+ const second = new MediaBridge({ sessionId: "reuse-call-2", a: a2, b: b2, logger: silentLogger, statsIntervalMs: 10000 });
495
+ await second.start();
496
+ for (let i = 0; i < 3; i += 1) a2.emitRtp(fakeRtpPacket(5555, i + 1));
497
+ for (let i = 0; i < 2; i += 1) b2.emitRtp(fakeRtpPacket(6666, i + 1));
498
+ const health = second.health();
499
+ assert.equal(health.aToB, 3);
500
+ assert.equal(health.bToA, 2);
501
+ await second.stop();
502
+ });
@@ -59,6 +59,19 @@ test("resolve targets the correct leg by endpoint", () => {
59
59
  assert.equal(toBob.leg.kind, "sip");
60
60
  });
61
61
 
62
+ test("resolve throws when target endpoint is not part of pair", () => {
63
+ const reg = buildRegistry();
64
+ assert.throws(
65
+ () =>
66
+ reg.resolve({
67
+ a: { endpoint: "alice", kind: "webrtc" },
68
+ b: { endpoint: "bob", kind: "sip" },
69
+ target: "carol",
70
+ }),
71
+ /target endpoint not found/i,
72
+ );
73
+ });
74
+
62
75
  test("different pair creates a different PolySession", () => {
63
76
  const reg = buildRegistry();
64
77
  const r1 = reg.resolve({ a: { endpoint: "alice", kind: "webrtc" }, b: { endpoint: "bob", kind: "webrtc" } });
@@ -68,6 +81,7 @@ test("different pair creates a different PolySession", () => {
68
81
 
69
82
  test("pairKey is order-independent and label-normalized", () => {
70
83
  assert.equal(pairKey("A.x.global", "b"), pairKey("b", "a"));
84
+ assert.equal(pairKey("Alice.SecNum.Global", "BOB"), pairKey("alice", "bob"));
71
85
  });
72
86
 
73
87
  test("destroy removes the PolySession and frees the key", async () => {
@@ -141,3 +155,61 @@ test("supports two simultaneous calls without cross-wiring state/media", async (
141
155
  assert.equal(second.poly.legs.b.state, S.IN_CALL);
142
156
  assert.equal(media.connects.length, 2, "media must bridge each call once");
143
157
  });
158
+
159
+ test("same callee with different callers keeps separate pair records", () => {
160
+ const reg = buildRegistry();
161
+ const first = reg.resolve({
162
+ a: { endpoint: "alice.secnum.global", kind: "webrtc" },
163
+ b: { endpoint: "*9225", kind: "sip" },
164
+ target: "a",
165
+ });
166
+ const second = reg.resolve({
167
+ a: { endpoint: "carol.secnum.global", kind: "webrtc" },
168
+ b: { endpoint: "*9225", kind: "sip" },
169
+ target: "a",
170
+ });
171
+
172
+ assert.notEqual(first.key, second.key);
173
+ assert.notEqual(first.poly, second.poly);
174
+ assert.equal(reg.get(first.key), first.poly);
175
+ assert.equal(reg.get(second.key), second.poly);
176
+ });
177
+
178
+ test("shared caller across AB and AC keeps pair-isolated lifecycle", async () => {
179
+ const { reg, media } = buildRegistryWithMedia();
180
+ const ab = reg.resolve({
181
+ a: { endpoint: "alice", kind: "webrtc" },
182
+ b: { endpoint: "bob", kind: "webrtc" },
183
+ target: "alice",
184
+ });
185
+ const ac = reg.resolve({
186
+ a: { endpoint: "alice", kind: "webrtc" },
187
+ b: { endpoint: "carol", kind: "webrtc" },
188
+ target: "alice",
189
+ });
190
+ assert.notEqual(ab.key, ac.key);
191
+ assert.notEqual(ab.poly, ac.poly);
192
+
193
+ await ab.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
194
+ await ab.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
195
+ await ac.poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
196
+ await ac.poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
197
+ await ab.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-ab" }));
198
+ await ab.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-ab" }));
199
+ await ac.poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "offer-ac" }));
200
+ await ac.poly.onIngress("b", makeLegEvent(LEG_EVENTS.ANSWER, { sdp: "answer-ac" }));
201
+
202
+ assert.equal(ab.poly.legs.a.state, S.IN_CALL);
203
+ assert.equal(ab.poly.legs.b.state, S.IN_CALL);
204
+ assert.equal(ac.poly.legs.a.state, S.IN_CALL);
205
+ assert.equal(ac.poly.legs.b.state, S.IN_CALL);
206
+ assert.equal(media.connects.length, 2);
207
+
208
+ await ab.poly.onIngress("a", makeLegEvent(LEG_EVENTS.END));
209
+ await ab.poly.onIngress("b", makeLegEvent(LEG_EVENTS.END_RENEGOTIATION, { type: "answer", sdp: "end-ab" }));
210
+
211
+ assert.equal(ab.poly.legs.a.state, S.CONNECTED);
212
+ assert.equal(ab.poly.legs.b.state, S.CONNECTED);
213
+ assert.equal(ac.poly.legs.a.state, S.IN_CALL, "ending AB must not affect AC");
214
+ assert.equal(ac.poly.legs.b.state, S.IN_CALL, "ending AB must not affect AC");
215
+ });
@@ -102,6 +102,16 @@ test("peer just rang: ackRing the caller (gated on the RINGING event)", () => {
102
102
  assert.deepEqual(intents(actions), [{ kind: "intent", leg: "a", intent: I.ACK_RING, from: "b" }]);
103
103
  });
104
104
 
105
+ test("glare: calling+calling stays user-driven and presents one ringing side", () => {
106
+ const actions = reconcile(snap(S.CALLING, S.CALLING, false), ringEvent);
107
+ assert.deepEqual(intents(actions), [
108
+ { kind: "intent", leg: "a", intent: I.ACK_CONNECTED, from: "self" },
109
+ { kind: "intent", leg: "b", intent: I.ACK_CONNECTED, from: "self" },
110
+ { kind: "intent", leg: "b", intent: I.RING, from: "a" },
111
+ ]);
112
+ assert.equal(intents(actions).some((x) => x.intent === I.ANSWER), false);
113
+ });
114
+
105
115
  test("no fresh-ring event: do not ack (a later pass must not re-ack the same ring)", () => {
106
116
  const actions = reconcile(snap(S.CALLING, S.CONNECTED, false), { state: S.CONNECTED });
107
117
  assert.deepEqual(intents(actions), [{ kind: "intent", leg: "b", intent: I.RING, from: "a" }]);