drachtio-srf 5.0.26 → 5.0.27

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/lib/srf.js CHANGED
@@ -587,6 +587,35 @@ class Srf extends Emitter {
587
587
  resolve(dialog);
588
588
  });
589
589
  },
590
+ // teardown for the case where the caller (e.g. createB2BUA)
591
+ // needs to abandon this answered-but-not-yet-acked B leg:
592
+ // ACK the 200 OK (echoing the remote answer back as our
593
+ // offer to complete the transaction) then BYE the dialog.
594
+ // Without this the un-acked 200 OK is retransmitted by the
595
+ // far end and the B-leg dialog leaks.
596
+ destroy: async() => {
597
+ try {
598
+ // NOTE: if the caller reaches destroy() after the ack()
599
+ // closure above already sent an ACK (e.g. ackFunction
600
+ // rejected on the no-Contact path *after* ack'ing), this
601
+ // sends a second ACK. Duplicate ACKs are tolerated by
602
+ // the far end, so this is harmless. The common failure
603
+ // path (localSdpB/localSdpA throwing before ackFunction
604
+ // runs) reaches here having sent no ACK at all.
605
+ ack({body: res.body});
606
+ if (!res.has('Contact')) {
607
+ // cannot form in-dialog BYE without a Contact; the
608
+ // ACK above is the best we can do to release it.
609
+ return;
610
+ }
611
+ const dialog = new Dialog(this, 'uac', {req, res, auth: opts.auth});
612
+ dialog.local.sdp = res.body;
613
+ this.addDialog(dialog);
614
+ await dialog.destroy().catch(() => {});
615
+ } catch(err) {
616
+ debug(`createUAC: error tearing down 3pcc B leg: ${err}`);
617
+ }
618
+ },
590
619
  res
591
620
  });
592
621
  }
@@ -1118,14 +1147,28 @@ class Srf extends Emitter {
1118
1147
  if (is3pcc) {
1119
1148
  debug('createB2BUA: successfully created UAS..but this is 3pcc, so a bit more work to do');
1120
1149
  uas.once('ack', async(ackRequest) => {
1121
- debug(`createB2BUA: got ACK from UAS, pass on sdp: ${ackRequest.body}`);
1122
- const sdp = await (typeof opts.localSdpB === 'function' ?
1123
- opts.localSdpB(ackRequest.body) : Promise.resolve(ackRequest.body));
1124
- uac = await ackFunction(sdp);
1125
- uac.other = uas;
1126
- uas.other = uac;
1127
- debug('createB2BUA: successfully created bot dialogs in 3pcc!');
1128
- return callback(null, {uac, uas}); // successfully connected! resolve promise with both dialogs
1150
+ // this handler runs after __x has already returned, so any throw
1151
+ // here escapes the createB2BUA promise chain and would become an
1152
+ // unhandled rejection. catch it, tear both legs down, and route
1153
+ // the error back to the caller's callback (which rejects the
1154
+ // returned promise).
1155
+ try {
1156
+ debug(`createB2BUA: got ACK from UAS, pass on sdp: ${ackRequest.body}`);
1157
+ const sdp = await (typeof opts.localSdpB === 'function' ?
1158
+ opts.localSdpB(ackRequest.body) : Promise.resolve(ackRequest.body));
1159
+ uac = await ackFunction(sdp);
1160
+ uac.other = uas;
1161
+ uas.other = uac;
1162
+ debug('createB2BUA: successfully created bot dialogs in 3pcc!');
1163
+ return callback(null, {uac, uas}); // successfully connected! resolve promise with both dialogs
1164
+ } catch(err) {
1165
+ debug({err}, 'createB2BUA: failed finalizing 3pcc dialogs on ACK');
1166
+ if (uac && typeof uac.destroy === 'function') {
1167
+ Promise.resolve(uac.destroy()).catch(() => {});
1168
+ }
1169
+ uas.destroy().catch(() => {});
1170
+ return callback(err);
1171
+ }
1129
1172
  });
1130
1173
  return;
1131
1174
  }
@@ -1140,21 +1183,49 @@ class Srf extends Emitter {
1140
1183
  return callback(null, {uac, uas}); // successfully connected! resolve promise with both dialogs
1141
1184
  } catch(err) {
1142
1185
  debug({err}, 'createB2BUA: failed creating UAS..done!');
1143
- uac && uac.destroy().catch(() => {}); // failed A leg after success on B: tear down B
1186
+ // failed A leg after success on B: tear down B.
1187
+ // in normal mode `uac` is a Dialog; in 3pcc/no-offer mode it is the
1188
+ // {sdp, ack, res, destroy} object from createUAC (the real dialog is
1189
+ // not created until the A-leg ACK). both expose destroy(), which for
1190
+ // the 3pcc object ACKs then BYEs the answered B leg so it is released
1191
+ // rather than leaked. the typeof guard is retained for safety in case
1192
+ // uac is ever some other shape.
1193
+ if (uac && typeof uac.destroy === 'function') {
1194
+ Promise.resolve(uac.destroy()).catch(() => {});
1195
+ }
1144
1196
  return callback(err);
1145
1197
  }
1146
1198
  };
1147
1199
 
1200
+ // wrap the callback so __x can invoke it exactly once; a late async throw
1201
+ // routed through .catch() must not double-invoke it.
1202
+ const once = (cb) => {
1203
+ let called = false;
1204
+ return (...args) => {
1205
+ if (called) return;
1206
+ called = true;
1207
+ return cb(...args);
1208
+ };
1209
+ };
1210
+
1148
1211
  if (callback) {
1149
- __x(callback);
1212
+ const cb = once(callback);
1213
+ // __x is async; if it throws after returning (e.g. a cleanup failure or
1214
+ // a throw in the detached 3pcc ack handler) surface it through the
1215
+ // callback instead of leaking a process-level unhandledRejection.
1216
+ __x(cb).catch((err) => cb(err));
1150
1217
  return this;
1151
1218
  }
1152
1219
 
1153
1220
  return new Promise((resolve, reject) => {
1154
- __x((err, dialogs) => {
1221
+ const settle = once((err, dialogs) => {
1155
1222
  if (err) return reject(err);
1156
1223
  resolve(dialogs);
1157
1224
  });
1225
+ // __x is async; if it throws after the callback path (e.g. a cleanup
1226
+ // failure), surface it as a promise rejection to the caller's
1227
+ // try/catch instead of leaking a process-level unhandledRejection.
1228
+ __x(settle).catch(settle);
1158
1229
  });
1159
1230
  }
1160
1231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drachtio-srf",
3
- "version": "5.0.26",
3
+ "version": "5.0.27",
4
4
  "description": "drachtio signaling resource framework",
5
5
  "main": "lib/srf.js",
6
6
  "types": "lib/@types/index.d.ts",
package/test/b2b.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const test = require('tape');
2
- const { output, sippUac } = require('./sipp')('test_testbed');
2
+ const { output, sippUac, sippUas } = require('./sipp')('test_testbed');
3
+ const { matchesUacDestroyCrash } = require('./scripts/crash-signatures');
3
4
  const B2b = require('./scripts/b2b');
4
5
  const debug = require('debug')('drachtio:test');
5
6
 
@@ -390,6 +391,59 @@ test('B2B', (t) => {
390
391
  });
391
392
  })
392
393
 
394
+ // 3PCC no-offer INVITE where UAS leg fails after B leg succeeds.
395
+ // Regression test for `uac.destroy is not a function` crash: the
396
+ // cleanup path must reject cleanly instead of throwing a TypeError
397
+ // that escapes as an unhandledRejection and crashes the worker.
398
+ //
399
+ // It ALSO pins the B-leg teardown: the sipp-uas scenario (uas.xml)
400
+ // sends 200 OK then requires ACK + BYE before it completes with exit 0.
401
+ // If createB2BUA failed to tear down the answered B leg (the residual
402
+ // leak), sipp-uas would never see the ACK/BYE and time out -> the
403
+ // sippUasB probe below rejects and this test fails.
404
+ .then(() => {
405
+ debug('starting sipp');
406
+ let sawCrashSignature = false;
407
+ // the bug surfaces as a process-level unhandledRejection (the promise
408
+ // wrapper around __x does not catch async throws); a correct fix
409
+ // surfaces it, if at all, as an app-side error event.
410
+ const onError = (err) => { if (matchesUacDestroyCrash(err)) sawCrashSignature = true; };
411
+ const onRejection = (reason) => { if (matchesUacDestroyCrash(reason)) sawCrashSignature = true; };
412
+ b2b.on('error', onError);
413
+ process.on('unhandledRejection', onRejection);
414
+ b2b.expect3pccUasFailure('sip:sipp-uas-b-teardown');
415
+ // start the B-leg UAS probe: it answers 200 OK and REQUIRES the
416
+ // subsequent ACK + BYE (from createB2BUA's 3pcc teardown) to succeed.
417
+ const sippUasB = sippUas('uas.xml', 'sipp-uas-b-teardown');
418
+ // give the UAS container a moment to start listening before the A-leg
419
+ // INVITE triggers the B-leg INVITE towards it.
420
+ return new Promise((resolve) => setTimeout(resolve, 1500))
421
+ .then(() => sippUac('uac-3pcc-nosdp.xml'))
422
+ .catch(() => {}) // A-leg gets a 480; its own exit code is not the assertion
423
+ .then(() => new Promise((resolve) => setTimeout(resolve, 250))) // let any late rejection surface
424
+ .then(() => {
425
+ b2b.removeListener('error', onError);
426
+ process.removeListener('unhandledRejection', onRejection);
427
+ if (sawCrashSignature) {
428
+ throw new Error('createB2BUA leaked `uac.destroy is not a function` on 3pcc UAS failure');
429
+ }
430
+ // the B-leg UAS must have received ACK + BYE (proving teardown).
431
+ return sippUasB;
432
+ });
433
+ })
434
+ .then(() => {
435
+ return t.pass('b2b 3pcc UAS failure does not crash and tears down the B leg');
436
+ })
437
+ .then(() => {
438
+ b2b.disconnect();
439
+ return new Promise((resolve, reject) => {
440
+ setTimeout(() => {
441
+ b2b = new B2b();
442
+ resolve();
443
+ }, 100);
444
+ });
445
+ })
446
+
393
447
  // very fast reinvite from B, before ACK from A
394
448
  .then(() => {
395
449
  debug('starting sipp');
@@ -0,0 +1,78 @@
1
+ <?xml version="1.0" encoding="ISO-8859-1" ?>
2
+ <!DOCTYPE scenario SYSTEM "sipp.dtd">
3
+
4
+ <!-- -->
5
+ <!-- 3PCC / no-offer UAC scenario. -->
6
+ <!-- -->
7
+ <!-- Sends an INVITE with NO SDP body (Content-Length: 0). This forces -->
8
+ <!-- drachtio-srf createB2BUA down the 3PCC (no-offer) path, where the -->
9
+ <!-- B-leg UAC dialog is not finalized until the A-leg ACK carries the -->
10
+ <!-- answer SDP. -->
11
+ <!-- -->
12
+ <!-- Used to reproduce the `uac.destroy is not a function` crash: when -->
13
+ <!-- the UAS leg fails AFTER the B leg has produced its {ack, res} -->
14
+ <!-- object, the createB2BUA cleanup path calls uac.destroy() on an -->
15
+ <!-- object that has no destroy() method. -->
16
+ <!-- -->
17
+
18
+ <scenario name="3PCC no-offer UAC">
19
+ <send retrans="500">
20
+ <![CDATA[
21
+
22
+ INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0
23
+ Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
24
+ From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
25
+ To: [service] <sip:[service]@[remote_ip]:[remote_port]>
26
+ Call-ID: [call_id]
27
+ CSeq: 1 INVITE
28
+ Contact: sip:sipp@[local_ip]:[local_port]
29
+ Max-Forwards: 70
30
+ Subject: uac-3pcc-nosdp
31
+ Content-Length: 0
32
+
33
+ ]]>
34
+ </send>
35
+
36
+ <recv response="100" optional="true">
37
+ </recv>
38
+
39
+ <recv response="180" optional="true">
40
+ </recv>
41
+
42
+ <recv response="183" optional="true">
43
+ </recv>
44
+
45
+ <!-- Because the UAS leg fails, the B2BUA app rejects and sends a 480 -->
46
+ <!-- failure final response to the A leg. The important assertion -->
47
+ <!-- (worker did not crash on uac.destroy) is made on the drachtio-srf -->
48
+ <!-- application side; here we simply complete the transaction. -->
49
+ <recv response="480" rtd="true">
50
+ </recv>
51
+
52
+ <!-- ACK the non-2xx final response to complete the INVITE -->
53
+ <!-- transaction (RFC 3261 - ACK for a failure response uses the same -->
54
+ <!-- branch/CSeq as the INVITE). -->
55
+ <send>
56
+ <![CDATA[
57
+
58
+ ACK sip:[service]@[remote_ip]:[remote_port] SIP/2.0
59
+ [last_Via]
60
+ From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
61
+ To: [service] <sip:[service]@[remote_ip]:[remote_port]>[peer_tag_param]
62
+ Call-ID: [call_id]
63
+ CSeq: 1 ACK
64
+ Contact: sip:sipp@[local_ip]:[local_port]
65
+ Max-Forwards: 70
66
+ Subject: uac-3pcc-nosdp
67
+ Content-Length: 0
68
+
69
+ ]]>
70
+ </send>
71
+
72
+ <!-- definition of the response time repartition table (unit is ms) -->
73
+ <ResponseTimeRepartition value="10, 20, 30, 40, 50, 100, 150, 200"/>
74
+
75
+ <!-- definition of the call length repartition table (unit is ms) -->
76
+ <CallLengthRepartition value="10, 50, 100, 500, 1000, 5000, 10000"/>
77
+
78
+ </scenario>
@@ -2,6 +2,7 @@ const Emitter = require('events');
2
2
  const Srf = require('../..');
3
3
  const config = require('config');
4
4
  const debug = require('debug')('drachtio:test');
5
+ const { matchesUacDestroyCrash } = require('./crash-signatures');
5
6
 
6
7
  class App extends Emitter {
7
8
  constructor() {
@@ -106,6 +107,58 @@ class App extends Emitter {
106
107
  });
107
108
  }
108
109
 
110
+ // Shared invite -> createB2BUA scaffold used by the expect* helpers.
111
+ // onConnected(dialogs, req, res) runs on success; onFailure(err, req, res)
112
+ // runs on rejection. Removes the duplicated invite/createB2BUA/then/catch
113
+ // boilerplate from each caller.
114
+ _inviteB2BUA(uri, opts, {onConnected, onFailure} = {}) {
115
+ this.srf.invite((req, res) => {
116
+ this.srf.createB2BUA(req, res, uri, opts)
117
+ .then((dialogs) => {
118
+ if (onConnected) return onConnected(dialogs, req, res);
119
+ this.emit('connected', dialogs);
120
+ })
121
+ .catch((err) => {
122
+ if (onFailure) return onFailure(err, req, res);
123
+ throw err;
124
+ });
125
+ });
126
+ }
127
+
128
+ // Reproduces the `uac.destroy is not a function` crash.
129
+ //
130
+ // The A leg INVITE arrives with no SDP, so createB2BUA takes the 3PCC
131
+ // (no-offer) path. The B leg UAC succeeds and returns a {ack, res}
132
+ // object (NOT a finalized dialog). We then force the UAS leg to fail
133
+ // by supplying a localSdpA function that throws. This drives execution
134
+ // into the createB2BUA catch block while `uac` is still the {ack, res}
135
+ // object, exercising the guarded uac.destroy() cleanup.
136
+ //
137
+ // Before the fix this threw a synchronous TypeError inside the catch,
138
+ // which surfaced as an unhandledRejection and crashed the worker.
139
+ // After the fix the promise rejects cleanly and this onFailure runs.
140
+ expect3pccUasFailure(uri) {
141
+ this._inviteB2BUA(uri, {
142
+ localSdpA: () => {
143
+ throw new Error('simulated SDP/route processing failure on UAS leg');
144
+ }
145
+ }, {
146
+ onConnected: () => {
147
+ this.emit('error', new Error('unexpected dialog success - expected UAS leg failure'));
148
+ },
149
+ onFailure: (err, req, res) => {
150
+ debug(`expect3pccUasFailure: got expected error ${err}`);
151
+ if (matchesUacDestroyCrash(err)) {
152
+ // the original bug leaked back to the app as a rejection
153
+ this.emit('error', err);
154
+ return;
155
+ }
156
+ this.emit('failed', err);
157
+ if (!res.finalResponseSent) res.send(480);
158
+ }
159
+ });
160
+ }
161
+
109
162
  immediateReinviteFromB(uri) {
110
163
  this.srf.invite((req, res) => {
111
164
 
@@ -0,0 +1,15 @@
1
+ // Single source of truth for the crash signature the 3pcc regression test
2
+ // watches for. Referenced by both the app-side helper (scripts/b2b.js) and
3
+ // the test assertion (b2b.js) so the two cannot drift out of sync.
4
+ //
5
+ // NOTE: this matches on the V8 TypeError wording. The underlying defect is
6
+ // "uac was the {ack,res} 3pcc object, not a Dialog", so if a future engine
7
+ // rewords the message this regex must be updated here (one place).
8
+ const UAC_DESTROY_CRASH = /uac\.destroy is not a function/;
9
+
10
+ const matchesUacDestroyCrash = (reason) => {
11
+ const message = typeof reason === 'string' ? reason : reason && reason.message;
12
+ return UAC_DESTROY_CRASH.test(message || '');
13
+ };
14
+
15
+ module.exports = { UAC_DESTROY_CRASH, matchesUacDestroyCrash };
package/test/sipp.js CHANGED
@@ -25,7 +25,6 @@ obj.output = () => {
25
25
  };
26
26
 
27
27
  obj.sippUac = (file) => {
28
- const cmd = 'docker';
29
28
  const args = [
30
29
  'run', '--rm', '--net', `${network}`,
31
30
  '-v', `${__dirname}/scenarios:/tmp/scenarios`,
@@ -38,9 +37,29 @@ obj.sippUac = (file) => {
38
37
  ];
39
38
 
40
39
  clearOutput();
40
+ return runSipp(args);
41
+ };
42
+
43
+ // Run a sipp UAS as a named, DNS-resolvable container on the test network so
44
+ // the SUT can route a B-leg INVITE to `sip:<containerName>`. Resolves when the
45
+ // scenario completes (exit 0), e.g. uas.xml requires INVITE -> 200 -> ACK -> BYE,
46
+ // so a resolved promise proves the B leg was answered AND torn down.
47
+ obj.sippUas = (file, containerName) => {
48
+ const args = [
49
+ 'run', '--rm', '--net', `${network}`, '--name', `${containerName}`,
50
+ '-v', `${__dirname}/scenarios:/tmp/scenarios`,
51
+ 'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`,
52
+ '-m', '1',
53
+ '-nostdin'
54
+ // no target host: UAS listens for incoming calls
55
+ ];
56
+
57
+ return runSipp(args);
58
+ };
41
59
 
60
+ function runSipp(args) {
42
61
  return new Promise((resolve, reject) => {
43
- const child_process = spawn(cmd, args, {stdio: ['inherit', 'pipe', 'pipe']});
62
+ const child_process = spawn('docker', args, {stdio: ['inherit', 'pipe', 'pipe']});
44
63
 
45
64
  child_process.on('exit', (code, signal) => {
46
65
  if (code === 0) {
@@ -54,12 +73,10 @@ obj.sippUac = (file) => {
54
73
  });
55
74
 
56
75
  child_process.stdout.on('data', (data) => {
57
- //debug(`stdout: ${data}`);
58
76
  addOutput(data.toString());
59
77
  });
60
- child_process.stdout.on('data', (data) => {
61
- //debug(`stdout: ${data}`);
78
+ child_process.stderr.on('data', (data) => {
62
79
  addOutput(data.toString());
63
80
  });
64
81
  });
65
- };
82
+ }