arnacon-webrtc-service 0.1.166 → 0.1.167

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.1.166",
3
+ "version": "0.1.167",
4
4
  "description": "Arnacon WebRTC core runtime and service modules",
5
5
  "main": "./webRTCservice/core.js",
6
6
  "type": "commonjs",
@@ -7,9 +7,22 @@
7
7
 
8
8
  const { reconcile } = require("./ReconcileRules");
9
9
  const { LEG_INTENTS } = require("./ports");
10
+ const { LEG_STATES, isActiveCall } = require("./states");
10
11
 
11
12
  const MAX_RECONCILE_PASSES = 50;
12
13
 
14
+ // Intents that drive a call toward the peer. If one of these throws (e.g. the SIP
15
+ // INVITE is rejected during `ring`), the leg can no longer carry the call -> mark
16
+ // it FAILED so reconcile ends the caller (end-call reneg). Teardown intents are
17
+ // excluded: failing a teardown into more teardown would loop.
18
+ const FAIL_ON_ERROR_INTENTS = new Set([
19
+ LEG_INTENTS.RING,
20
+ LEG_INTENTS.CONNECT,
21
+ LEG_INTENTS.ANSWER,
22
+ LEG_INTENTS.ACK_CONNECTED,
23
+ LEG_INTENTS.ACK_RING,
24
+ ]);
25
+
13
26
  class PolySession {
14
27
  constructor({ id, legA, legB, mediaController, rules = reconcile, teardownHooks = [], logger = console } = {}) {
15
28
  if (!legA || !legB) throw new Error("PolySession requires two legs");
@@ -95,6 +108,7 @@ class PolySession {
95
108
  for (const action of actions) {
96
109
  await this._execute(action);
97
110
  }
111
+ this._recoverFailedLegs();
98
112
  }
99
113
  } finally {
100
114
  this._running = false;
@@ -129,10 +143,33 @@ class PolySession {
129
143
  }
130
144
  } catch (err) {
131
145
  this.logger.error(`[${this.id}] intent ${action.intent} on leg ${leg.id} failed: ${err.message}`);
146
+ // A reaching intent failed -> the leg cannot carry the call. Fail it so
147
+ // reconcile drives the peer into an end-call reneg (a FAILED leg is a
148
+ // teardown trigger). Idempotent: no-op if already FAILED/torn down.
149
+ if (FAIL_ON_ERROR_INTENTS.has(action.intent) && leg.state !== LEG_STATES.FAILED) {
150
+ leg.setState(LEG_STATES.FAILED, { reason: `intent-failed:${action.intent}`, from: "self" });
151
+ }
132
152
  }
133
153
  return undefined;
134
154
  }
135
155
 
156
+ // Once the call is over, a transportless leg left FAILED (e.g. a SIP INVITE that
157
+ // was rejected during ring) is stuck: FAILED is not rungable, so the next call
158
+ // would stall. Reset it to its idle (DISCONNECTED) so reconcile can re-drive it
159
+ // (the SIP leg rebuilds its INVITE per call). Scoped to SIP: a webrtc leg's
160
+ // FAILED means its actual PC died, so it must NOT be silently revived here.
161
+ // Only when the peer is no longer in an active call, so we never wipe a leg
162
+ // whose end-call reneg is still in flight.
163
+ _recoverFailedLegs() {
164
+ for (const ref of ["a", "b"]) {
165
+ const leg = this.legs[ref];
166
+ const peer = this.legs[ref === "a" ? "b" : "a"];
167
+ if (leg.kind === "sip" && leg.state === LEG_STATES.FAILED && !isActiveCall(peer.state)) {
168
+ leg.setState(LEG_STATES.DISCONNECTED, { reason: "failed-leg-recovery", from: "self" });
169
+ }
170
+ }
171
+ }
172
+
136
173
  async _executeMedia(action) {
137
174
  try {
138
175
  if (action.op === "connect") {
@@ -182,6 +182,40 @@ test("webrtc<->sip: remote BYE on sip leg ends the webrtc caller", async () => {
182
182
  assert.equal(a.leg.state, S.CONNECTED);
183
183
  });
184
184
 
185
+ test("sip ring failure ends the webrtc caller, then the sip leg recovers for reuse", async () => {
186
+ const a = makeWebRtcLeg("alice");
187
+ const b = makeSipLeg("+15551234");
188
+ const { poly, media } = buildPoly(a, b);
189
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
190
+ await poly.onIngress("b", makeLegEvent(LEG_EVENTS.TRANSPORT_OPEN));
191
+
192
+ // The SIP INVITE is rejected before it establishes -> ring() rejects.
193
+ const realRing = b.negotiation.ring.bind(b.negotiation);
194
+ b.negotiation.ring = async () => { throw new Error("SIP call terminated before established"); };
195
+
196
+ // Caller offers -> P rings the sip leg, which fails -> sip FAILED -> P ends the
197
+ // caller via an end-call reneg (no media ever bridged).
198
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o" }));
199
+ assert.equal(b.leg.state, S.FAILED, "sip leg failed its ring");
200
+ assert.equal(a.leg.state, S.ENDING, "caller is ended on the failed ring");
201
+ assert.equal(a.negotiation.named("endCall").length, 1);
202
+ assert.equal(media.connects.length, 0, "no media bridged for a failed ring");
203
+
204
+ // Caller's client answers the end-call offer -> caller back to connected, and
205
+ // the stuck FAILED sip leg recovers to its idle (DISCONNECTED) so it is reusable.
206
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.END_RENEGOTIATION, { type: "answer", sdp: "end-ans" }));
207
+ assert.equal(a.leg.state, S.CONNECTED);
208
+ assert.equal(b.leg.state, S.DISCONNECTED, "failed sip leg recovered to idle for reuse");
209
+
210
+ // Reuse: sip is reachable again -> a fresh offer connects + rings the sip leg
211
+ // (a blocking INVITE that answers -> both in-call, media bridged).
212
+ b.negotiation.ring = realRing;
213
+ await poly.onIngress("a", makeLegEvent(LEG_EVENTS.OFFER, { sdp: "o2" }));
214
+ assert.equal(a.leg.state, S.IN_CALL);
215
+ assert.equal(b.leg.state, S.IN_CALL, "sip leg rung again on reuse");
216
+ assert.equal(media.connects.length, 1, "media bridged on the reused call");
217
+ });
218
+
185
219
  test("reject before answer: caller is ended, no media ever bridged", async () => {
186
220
  const a = makeWebRtcLeg("alice");
187
221
  const b = makeWebRtcLeg("bob");
@@ -27,6 +27,12 @@ test("teardown beats progress: inCall vs failed (dropped) ends the inCall side,
27
27
  assert.deepEqual(ends[0], { kind: "intent", leg: "a", intent: I.END, from: "b" });
28
28
  });
29
29
 
30
+ test("calling vs failed (e.g. sip ring rejected) ends the calling caller", () => {
31
+ const actions = reconcile(snap(S.CALLING, S.FAILED, false));
32
+ assert.deepEqual(mediaOps(actions), []);
33
+ assert.deepEqual(intents(actions), [{ kind: "intent", leg: "a", intent: I.END, from: "b" }]);
34
+ });
35
+
30
36
  test("inCall vs disconnected (peer gone, e.g. sip BYE) ends the inCall side, stops media", () => {
31
37
  // You cannot be in a call by yourself: a disconnected peer ends the inCall leg.
32
38
  const actions = reconcile(snap(S.IN_CALL, S.DISCONNECTED, true));
@@ -39,14 +39,14 @@ function createPeerConnectionFactory({
39
39
  s.connectionState = state;
40
40
  session.connectionState = state;
41
41
  if (shouldRequestDestroy && (state === "failed" || state === "closed")) {
42
- onSessionDestroyRequested(sessionId, { source: "webrtc", reason: `peer-connection-${state}`, notify: true });
42
+ onSessionDestroyRequested(sessionId, { source: "webrtc", reason: `peer-connection-${state}`, notify: true, pc });
43
43
  } else if (state === "disconnected") {
44
44
  if (!s.disconnectTimer) {
45
45
  s.disconnectTimer = setTimeout(() => {
46
46
  s.disconnectTimer = null;
47
47
  const current = sessions.get(sessionId);
48
48
  if (shouldRequestDestroy && current && session.peerConnection === pc && session.connectionState === "disconnected") {
49
- onSessionDestroyRequested(sessionId, { source: "webrtc", reason: "peer-connection-disconnected", notify: true });
49
+ onSessionDestroyRequested(sessionId, { source: "webrtc", reason: "peer-connection-disconnected", notify: true, pc });
50
50
  }
51
51
  }, 5000);
52
52
  }
@@ -1166,6 +1166,15 @@ function isSessionInCall(session) {
1166
1166
  // Terminal PC state -> tear the pair down and clean up.
1167
1167
  async function onTransportClosed(sessionId, event = {}) {
1168
1168
  const session = sessions.get(sessionId);
1169
+ // A 2nd call reuses the same sessionId on a fresh transport. The old call's PC
1170
+ // can fire `closed` after the new session is already bound under this id. If the
1171
+ // closing transport is no longer the session's current caller/callee PC, it is
1172
+ // superseded -> ignore it entirely so we don't tear down the fresh session.
1173
+ if (session && event.pc &&
1174
+ event.pc !== session.peerConnection &&
1175
+ event.pc !== session.outboundWebrtc?.peerConnection) {
1176
+ return;
1177
+ }
1169
1178
  const poly = polyForSession(session);
1170
1179
  // Only tear the poly down if it actually owns this closing transport. A stale
1171
1180
  // PC closing after a new-ring rebuild resolves (by identity) to the freshly