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.
- package/package.json +1 -1
- package/services/secnum.json +5 -2
- package/webRTCservice/modules/calls/inbound/InboundCallFlow.js +3 -10
- package/webRTCservice/modules/calls/poly/PolyIngress.js +3 -0
- package/webRTCservice/modules/calls/poly/PolySession.js +62 -2
- package/webRTCservice/modules/calls/poly/PolySessionRegistry.js +24 -17
- package/webRTCservice/modules/calls/poly/ReconcileRules.js +7 -0
- package/webRTCservice/modules/calls/poly/SessionLeg.js +7 -0
- package/webRTCservice/modules/calls/poly/__tests__/PolyIngress.test.js +6 -0
- package/webRTCservice/modules/calls/poly/__tests__/PolySession.test.js +155 -4
- package/webRTCservice/modules/calls/poly/__tests__/PolySessionRegistry.test.js +72 -0
- package/webRTCservice/modules/calls/poly/__tests__/ReconcileRules.test.js +10 -0
- package/webRTCservice/modules/calls/poly/__tests__/Scenarios.test.js +331 -0
- package/webRTCservice/modules/calls/poly/__tests__/SessionLeg.test.js +8 -0
- package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.test.js +10 -0
- package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.werift.integration.test.js +13 -0
- package/webRTCservice/modules/calls/poly/__tests__/helpers/scenarioHelpers.js +9 -4
- package/webRTCservice/modules/calls/poly/createPolyCore.js +14 -2
- package/webRTCservice/modules/calls/poly/negotiation/WebRtcNegotiation.js +3 -10
- package/webRTCservice/modules/calls/runtime/SessionResources.js +9 -0
- package/webRTCservice/modules/calls/webrtc/WebRtcOfferUseCase.js +83 -39
- package/webRTCservice/modules/calls/webrtc/WebRtcOutboundLegFactory.js +9 -1
- package/webRTCservice/modules/gateways/sip/SipClient.js +54 -0
- package/webRTCservice/modules/media/legs/SipLeg.js +7 -0
- package/webRTCservice/modules/media/legs/WebRtcClientLeg.js +48 -4
- package/webRTCservice/modules/media/werift/PeerConnectionFactory.js +8 -1
- package/webRTCservice/modules/media/werift/SsrcRouterPatch.js +27 -23
- package/webRTCservice/modules/participants/signaling/SignalingEnvelope.js +3 -10
- package/webRTCservice/modules/participants/signaling/SignalingPipeline.js +34 -47
- package/webRTCservice/modules/runtime/CallPairRef.js +46 -0
- package/webRTCservice/modules/runtime/CallPairResolver.js +76 -0
- package/webRTCservice/modules/runtime/SessionStore.js +11 -11
- package/webRTCservice/modules/server/HttpServer.js +14 -3
- package/webRTCservice/webRTCmanager.js +561 -63
package/package.json
CHANGED
package/services/secnum.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"providerId": "secnum",
|
|
4
4
|
"static": {
|
|
5
5
|
"status": "active",
|
|
6
|
-
"domains": ["secnumtest.global", "secnum.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": [
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
60
|
-
if (
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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)
|
|
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)
|
|
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.
|
|
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, "
|
|
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" }]);
|