speechflow 1.3.0 → 1.3.2

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 (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +165 -22
  3. package/dst/speechflow-node-a2a-gender.d.ts +2 -0
  4. package/dst/speechflow-node-a2a-gender.js +137 -59
  5. package/dst/speechflow-node-a2a-gender.js.map +1 -1
  6. package/dst/speechflow-node-a2a-meter.d.ts +3 -1
  7. package/dst/speechflow-node-a2a-meter.js +79 -35
  8. package/dst/speechflow-node-a2a-meter.js.map +1 -1
  9. package/dst/speechflow-node-a2a-mute.d.ts +1 -0
  10. package/dst/speechflow-node-a2a-mute.js +37 -11
  11. package/dst/speechflow-node-a2a-mute.js.map +1 -1
  12. package/dst/speechflow-node-a2a-vad.d.ts +3 -0
  13. package/dst/speechflow-node-a2a-vad.js +194 -96
  14. package/dst/speechflow-node-a2a-vad.js.map +1 -1
  15. package/dst/speechflow-node-a2a-wav.js +27 -11
  16. package/dst/speechflow-node-a2a-wav.js.map +1 -1
  17. package/dst/speechflow-node-a2t-deepgram.d.ts +4 -0
  18. package/dst/speechflow-node-a2t-deepgram.js +141 -43
  19. package/dst/speechflow-node-a2t-deepgram.js.map +1 -1
  20. package/dst/speechflow-node-t2a-elevenlabs.d.ts +2 -0
  21. package/dst/speechflow-node-t2a-elevenlabs.js +61 -12
  22. package/dst/speechflow-node-t2a-elevenlabs.js.map +1 -1
  23. package/dst/speechflow-node-t2a-kokoro.d.ts +1 -0
  24. package/dst/speechflow-node-t2a-kokoro.js +10 -4
  25. package/dst/speechflow-node-t2a-kokoro.js.map +1 -1
  26. package/dst/speechflow-node-t2t-deepl.js +8 -4
  27. package/dst/speechflow-node-t2t-deepl.js.map +1 -1
  28. package/dst/speechflow-node-t2t-format.js +2 -2
  29. package/dst/speechflow-node-t2t-format.js.map +1 -1
  30. package/dst/speechflow-node-t2t-ollama.js +1 -1
  31. package/dst/speechflow-node-t2t-ollama.js.map +1 -1
  32. package/dst/speechflow-node-t2t-openai.js +1 -1
  33. package/dst/speechflow-node-t2t-openai.js.map +1 -1
  34. package/dst/speechflow-node-t2t-sentence.d.ts +1 -1
  35. package/dst/speechflow-node-t2t-sentence.js +35 -24
  36. package/dst/speechflow-node-t2t-sentence.js.map +1 -1
  37. package/dst/speechflow-node-t2t-subtitle.js +85 -17
  38. package/dst/speechflow-node-t2t-subtitle.js.map +1 -1
  39. package/dst/speechflow-node-t2t-transformers.js +2 -2
  40. package/dst/speechflow-node-t2t-transformers.js.map +1 -1
  41. package/dst/speechflow-node-x2x-filter.js +4 -4
  42. package/dst/speechflow-node-x2x-trace.js +1 -1
  43. package/dst/speechflow-node-x2x-trace.js.map +1 -1
  44. package/dst/speechflow-node-xio-device.js +12 -8
  45. package/dst/speechflow-node-xio-device.js.map +1 -1
  46. package/dst/speechflow-node-xio-file.js +9 -3
  47. package/dst/speechflow-node-xio-file.js.map +1 -1
  48. package/dst/speechflow-node-xio-mqtt.js +5 -2
  49. package/dst/speechflow-node-xio-mqtt.js.map +1 -1
  50. package/dst/speechflow-node-xio-websocket.js +11 -11
  51. package/dst/speechflow-node-xio-websocket.js.map +1 -1
  52. package/dst/speechflow-utils.d.ts +5 -0
  53. package/dst/speechflow-utils.js +77 -44
  54. package/dst/speechflow-utils.js.map +1 -1
  55. package/dst/speechflow.js +104 -34
  56. package/dst/speechflow.js.map +1 -1
  57. package/etc/eslint.mjs +1 -2
  58. package/etc/speechflow.yaml +18 -7
  59. package/etc/stx.conf +3 -3
  60. package/package.json +14 -13
  61. package/src/speechflow-node-a2a-gender.ts +148 -64
  62. package/src/speechflow-node-a2a-meter.ts +87 -40
  63. package/src/speechflow-node-a2a-mute.ts +39 -11
  64. package/src/speechflow-node-a2a-vad.ts +206 -100
  65. package/src/speechflow-node-a2a-wav.ts +27 -11
  66. package/src/speechflow-node-a2t-deepgram.ts +148 -45
  67. package/src/speechflow-node-t2a-elevenlabs.ts +65 -12
  68. package/src/speechflow-node-t2a-kokoro.ts +11 -4
  69. package/src/speechflow-node-t2t-deepl.ts +9 -4
  70. package/src/speechflow-node-t2t-format.ts +2 -2
  71. package/src/speechflow-node-t2t-ollama.ts +1 -1
  72. package/src/speechflow-node-t2t-openai.ts +1 -1
  73. package/src/speechflow-node-t2t-sentence.ts +38 -27
  74. package/src/speechflow-node-t2t-subtitle.ts +62 -15
  75. package/src/speechflow-node-t2t-transformers.ts +4 -3
  76. package/src/speechflow-node-x2x-filter.ts +4 -4
  77. package/src/speechflow-node-x2x-trace.ts +1 -1
  78. package/src/speechflow-node-xio-device.ts +12 -8
  79. package/src/speechflow-node-xio-file.ts +9 -3
  80. package/src/speechflow-node-xio-mqtt.ts +5 -2
  81. package/src/speechflow-node-xio-websocket.ts +12 -12
  82. package/src/speechflow-utils.ts +78 -44
  83. package/src/speechflow.ts +117 -36
@@ -24,11 +24,19 @@ export function audioBufferDuration (
24
24
  channels = 1,
25
25
  littleEndian = true
26
26
  ) {
27
+ /* sanity check parameters */
27
28
  if (!Buffer.isBuffer(buffer))
28
29
  throw new Error("invalid input (Buffer expected)")
29
30
  if (littleEndian !== true)
30
31
  throw new Error("only Little Endian supported")
32
+ if (sampleRate <= 0)
33
+ throw new Error("sample rate must be positive")
34
+ if (bitDepth <= 0 || bitDepth % 8 !== 0)
35
+ throw new Error("bit depth must be positive and multiple of 8")
36
+ if (channels <= 0)
37
+ throw new Error("channels must be positive")
31
38
 
39
+ /* calculate duration */
32
40
  const bytesPerSample = bitDepth / 8
33
41
  const totalSamples = buffer.length / (bytesPerSample * channels)
34
42
  return totalSamples / sampleRate
@@ -40,12 +48,23 @@ export function audioArrayDuration (
40
48
  sampleRate = 48000,
41
49
  channels = 1
42
50
  ) {
51
+ /* sanity check parameters */
52
+ if (arr.length === 0)
53
+ return 0
54
+ if (sampleRate <= 0)
55
+ throw new Error("sample rate must be positive")
56
+ if (channels <= 0)
57
+ throw new Error("channels must be positive")
58
+
59
+ /* calculate duration */
43
60
  const totalSamples = arr.length / channels
44
61
  return totalSamples / sampleRate
45
62
  }
46
63
 
47
64
  /* helper function: convert Buffer in PCM/I16 to Float32Array in PCM/F32 format */
48
65
  export function convertBufToF32 (buf: Buffer, littleEndian = true) {
66
+ if (buf.length % 2 !== 0)
67
+ throw new Error("buffer length must be even for 16-bit samples")
49
68
  const dataView = new DataView(buf.buffer)
50
69
  const arr = new Float32Array(buf.length / 2)
51
70
  for (let i = 0; i < arr.length; i++)
@@ -55,9 +74,15 @@ export function convertBufToF32 (buf: Buffer, littleEndian = true) {
55
74
 
56
75
  /* helper function: convert Float32Array in PCM/F32 to Buffer in PCM/I16 format */
57
76
  export function convertF32ToBuf (arr: Float32Array) {
77
+ if (arr.length === 0)
78
+ return Buffer.alloc(0)
58
79
  const int16Array = new Int16Array(arr.length)
59
- for (let i = 0; i < arr.length; i++)
60
- int16Array[i] = Math.max(-32768, Math.min(32767, Math.round(arr[i] * 32768)))
80
+ for (let i = 0; i < arr.length; i++) {
81
+ let sample = arr[i]
82
+ if (Number.isNaN(sample))
83
+ sample = 0
84
+ int16Array[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32768)))
85
+ }
61
86
  return Buffer.from(int16Array.buffer)
62
87
  }
63
88
 
@@ -274,26 +299,19 @@ export class QueuePointer<T extends QueueElement> extends EventEmitter {
274
299
  }
275
300
  position (index?: number): number {
276
301
  if (index !== undefined) {
277
- this.index = index
278
- if (this.index < 0)
279
- this.index = 0
280
- else if (this.index >= this.queue.elements.length)
281
- this.index = this.queue.elements.length
302
+ this.index = Math.max(0, Math.min(index, this.queue.elements.length))
282
303
  this.emit("position", this.index)
283
304
  }
284
305
  return this.index
285
306
  }
286
307
  walk (num: number) {
287
- if (num > 0) {
288
- for (let i = 0; i < num && this.index < this.queue.elements.length; i++)
289
- this.index++
308
+ const indexOld = this.index
309
+ if (num > 0)
310
+ this.index = Math.min(this.index + num, this.queue.elements.length)
311
+ else if (num < 0)
312
+ this.index = Math.max(this.index + num, 0)
313
+ if (this.index !== indexOld)
290
314
  this.emit("position", { start: this.index })
291
- }
292
- else if (num < 0) {
293
- for (let i = 0; i < Math.abs(num) && this.index > 0; i++)
294
- this.index--
295
- this.emit("position", { start: this.index })
296
- }
297
315
  }
298
316
  walkForwardUntil (type: T["type"]) {
299
317
  while (this.index < this.queue.elements.length
@@ -330,12 +348,7 @@ export class QueuePointer<T extends QueueElement> extends EventEmitter {
330
348
  peek (position?: number) {
331
349
  if (position === undefined)
332
350
  position = this.index
333
- else {
334
- if (position < 0)
335
- position = 0
336
- else if (position > this.queue.elements.length)
337
- position = this.queue.elements.length
338
- }
351
+ position = Math.max(0, Math.min(position, this.queue.elements.length))
339
352
  const element = this.queue.elements[position]
340
353
  this.queue.emit("read", { start: position, end: position })
341
354
  return element
@@ -351,11 +364,8 @@ export class QueuePointer<T extends QueueElement> extends EventEmitter {
351
364
  let slice: T[]
352
365
  const start = this.index
353
366
  if (size !== undefined) {
354
- if (size < 0)
355
- size = 0
356
- else if (size > this.queue.elements.length - this.index)
357
- size = this.queue.elements.length - this.index
358
- slice = this.queue.elements.slice(this.index, size)
367
+ size = Math.max(0, Math.min(size, this.queue.elements.length - this.index))
368
+ slice = this.queue.elements.slice(this.index, this.index + size)
359
369
  this.index += size
360
370
  }
361
371
  else {
@@ -415,45 +425,58 @@ export class Queue<T extends QueueElement> extends EventEmitter {
415
425
  min = pointer.position()
416
426
 
417
427
  /* trim the maximum amount of first elements */
418
- this.elements.splice(0, min)
428
+ if (min > 0) {
429
+ this.elements.splice(0, min)
419
430
 
420
- /* shift all pointers */
421
- for (const pointer of this.pointers.values())
422
- pointer.position(pointer.position() - min)
431
+ /* shift all pointers */
432
+ for (const pointer of this.pointers.values())
433
+ pointer.position(pointer.position() - min)
434
+ }
423
435
  }
424
436
  }
425
437
 
426
438
  /* utility class for wrapping a custom stream into a regular Transform stream */
427
439
  export class StreamWrapper extends Stream.Transform {
428
440
  private foreignStream: any
441
+ private onData = (chunk: any) => { this.push(chunk) }
442
+ private onError = (err: Error) => { this.emit("error", err) }
443
+ private onEnd = () => { this.push(null) }
429
444
  constructor (foreignStream: any, options: Stream.TransformOptions = {}) {
430
445
  options.readableObjectMode = true
431
446
  options.writableObjectMode = true
432
447
  super(options)
433
448
  this.foreignStream = foreignStream
434
- this.foreignStream.on("data", (chunk: any) => {
435
- this.push(chunk)
436
- })
437
- this.foreignStream.on("error", (err: Error) => {
438
- this.emit("error", err)
439
- })
440
- this.foreignStream.on("end", () => {
441
- this.push(null)
442
- })
449
+ if (typeof this.foreignStream.on === "function") {
450
+ this.foreignStream.on("data", this.onData)
451
+ this.foreignStream.on("error", this.onError)
452
+ this.foreignStream.on("end", this.onEnd)
453
+ }
443
454
  }
444
455
  _transform (chunk: any, encoding: BufferEncoding, callback: Stream.TransformCallback): void {
456
+ if (this.destroyed) {
457
+ callback(new Error("stream already destroyed"))
458
+ return
459
+ }
445
460
  try {
446
- const canContinue = this.foreignStream.write(chunk)
447
- if (canContinue)
448
- callback()
461
+ if (typeof this.foreignStream.write === "function") {
462
+ const canContinue = this.foreignStream.write(chunk)
463
+ if (canContinue)
464
+ callback()
465
+ else
466
+ this.foreignStream.once("drain", callback)
467
+ }
449
468
  else
450
- this.foreignStream.once("drain", callback)
469
+ throw new Error("foreign stream lacks write method")
451
470
  }
452
471
  catch (err) {
453
472
  callback(err as Error)
454
473
  }
455
474
  }
456
475
  _flush (callback: Stream.TransformCallback): void {
476
+ if (this.destroyed) {
477
+ callback(new Error("stream already destroyed"))
478
+ return
479
+ }
457
480
  try {
458
481
  if (typeof this.foreignStream.end === "function")
459
482
  this.foreignStream.end()
@@ -463,6 +486,14 @@ export class StreamWrapper extends Stream.Transform {
463
486
  callback(err as Error)
464
487
  }
465
488
  }
489
+ _destroy (error: Error | null, callback: Stream.TransformCallback): void {
490
+ if (typeof this.foreignStream.removeListener === "function") {
491
+ this.foreignStream.removeListener("data", this.onData)
492
+ this.foreignStream.removeListener("error", this.onError)
493
+ this.foreignStream.removeListener("end", this.onEnd)
494
+ }
495
+ super._destroy(error, callback)
496
+ }
466
497
  }
467
498
 
468
499
  /* meta store */
@@ -485,4 +516,7 @@ export class TimeStore<T> extends EventEmitter {
485
516
  if (interval.low < before && interval.high < before)
486
517
  this.tree.remove(interval)
487
518
  }
519
+ clear (): void {
520
+ this.tree = new IntervalTree.IntervalTree<TimeStoreInterval<T>>()
521
+ }
488
522
  }
package/src/speechflow.ts CHANGED
@@ -176,6 +176,21 @@ type wsPeerInfo = {
176
176
  logPrefix: pkg.name
177
177
  })
178
178
 
179
+ /* catch uncaught exceptions */
180
+ process.on("uncaughtException", (err) => {
181
+ cli!.log("error", `uncaught exception: ${err.message}`)
182
+ process.exit(1)
183
+ })
184
+
185
+ /* catch unhandled promise rejections */
186
+ process.on("unhandledRejection", (reason) => {
187
+ if (reason instanceof Error)
188
+ cli!.log("error", `unhandled rejection: ${reason.message}`)
189
+ else
190
+ cli!.log("error", `unhandled rejection: ${reason}`)
191
+ process.exit(1)
192
+ })
193
+
179
194
  /* provide startup information */
180
195
  cli.log("info", `starting SpeechFlow ${pkg["x-stdver"]} (${pkg["x-release"]})`)
181
196
 
@@ -185,21 +200,6 @@ type wsPeerInfo = {
185
200
  for (const key of Object.keys(result.parsed))
186
201
  cli.log("info", `loaded environment variable "${key}" from ".env" files`)
187
202
 
188
- /* handle uncaught exceptions */
189
- process.on("uncaughtException", async (err: Error) => {
190
- cli!.log("warning", `process crashed with a fatal error: ${err} ${err.stack}`)
191
- process.exit(1)
192
- })
193
-
194
- /* handle unhandled promise rejections */
195
- process.on("unhandledRejection", async (reason, promise) => {
196
- if (reason instanceof Error)
197
- cli!.log("error", `promise rejection not handled: ${reason.message}: ${reason.stack}`)
198
- else
199
- cli!.log("error", `promise rejection not handled: ${reason}`)
200
- process.exit(1)
201
- })
202
-
203
203
  /* sanity check usage */
204
204
  let n = 0
205
205
  if (typeof args.e === "string" && args.e !== "") n++
@@ -220,7 +220,16 @@ type wsPeerInfo = {
220
220
  throw new Error("invalid configuration file specification (expected \"<id>@<yaml-config-file>\")")
221
221
  const [ , id, file ] = m
222
222
  const yaml = await cli.input(file, { encoding: "utf8" })
223
- const obj: any = jsYAML.load(yaml)
223
+ let obj: any
224
+ try {
225
+ obj = jsYAML.load(yaml)
226
+ }
227
+ catch (err) {
228
+ if (err instanceof Error)
229
+ throw new Error(`failed to parse YAML configuration: ${err.message}`)
230
+ else
231
+ throw new Error(`failed to parse YAML configuration: ${err}`)
232
+ }
224
233
  if (obj[id] === undefined)
225
234
  throw new Error(`no such id "${id}" found in configuration file`)
226
235
  config = obj[id] as string
@@ -308,7 +317,14 @@ type wsPeerInfo = {
308
317
  for (const name of Object.keys(nodes)) {
309
318
  cli!.log("info", `gathering status of node <${name}>`)
310
319
  const node = new nodes[name](name, cfg, {}, [])
311
- const status = await node.status()
320
+ const status = await Promise.race<{ [ key: string ]: string | number }>([
321
+ node.status(),
322
+ new Promise((resolve, reject) => setTimeout(() =>
323
+ reject(new Error("timeout")), 10 * 1000))
324
+ ]).catch((err: Error) => {
325
+ cli!.log("warning", `[${node.id}]: failed to gather status of node <${node.id}>: ${err.message}`)
326
+ return {} as { [ key: string ]: string | number }
327
+ })
312
328
  if (Object.keys(status).length > 0) {
313
329
  let first = true
314
330
  for (const key of Object.keys(status)) {
@@ -337,9 +353,9 @@ type wsPeerInfo = {
337
353
  }
338
354
  catch (err) {
339
355
  if (err instanceof Error && err.name === "FlowLinkError")
340
- cli!.log("error", `failed to parse SpeechFlow configuration: ${err.toString()}"`)
356
+ cli!.log("error", `failed to parse SpeechFlow configuration: ${err.toString()}`)
341
357
  else if (err instanceof Error)
342
- cli!.log("error", `failed to parse SpeechFlow configuration: ${err.message}"`)
358
+ cli!.log("error", `failed to parse SpeechFlow configuration: ${err.message}`)
343
359
  else
344
360
  cli!.log("error", "failed to parse SpeechFlow configuration: internal error")
345
361
  process.exit(1)
@@ -438,9 +454,13 @@ type wsPeerInfo = {
438
454
  /* open node */
439
455
  cli!.log("info", `open node <${node.id}>`)
440
456
  node.setTimeZero(timeZero)
441
- await node.open().catch((err: Error) => {
442
- cli!.log("error", `[${node.id}]: ${err.message}`)
443
- throw new Error(`failed to open node <${node.id}>`)
457
+ await Promise.race<void>([
458
+ node.open(),
459
+ new Promise((resolve, reject) => setTimeout(() =>
460
+ reject(new Error("timeout")), 10 * 1000))
461
+ ]).catch((err: Error) => {
462
+ cli!.log("error", `[${node.id}]: failed to open node <${node.id}>: ${err.message}`)
463
+ throw new Error(`failed to open node <${node.id}>: ${err.message}`)
444
464
  })
445
465
  }
446
466
 
@@ -462,9 +482,10 @@ type wsPeerInfo = {
462
482
  }
463
483
  }
464
484
 
465
- /* graph processing: PASS 6: track stream finishing */
485
+ /* graph processing: PASS 5: track stream finishing */
466
486
  const activeNodes = new Set<SpeechFlowNode>()
467
487
  const finishEvents = new EventEmitter()
488
+ finishEvents.setMaxListeners(graphNodes.size + 10)
468
489
  for (const node of graphNodes) {
469
490
  if (node.stream === null)
470
491
  throw new Error(`stream of node <${node.id}> still not initialized`)
@@ -507,12 +528,17 @@ type wsPeerInfo = {
507
528
  const name = req.node as string
508
529
  const args = req.args as any[]
509
530
  const foundNode = Array.from(graphNodes).find((node) => node.id === name)
510
- if (foundNode === undefined)
531
+ if (foundNode === undefined) {
511
532
  cli!.log("warning", `external request failed: no such node <${name}>`)
533
+ throw new Error(`external request failed: no such node <${name}>`)
534
+ }
512
535
  else {
513
- await foundNode.receiveRequest(args).catch((err: Error) => {
514
- cli!.log("warning", `external request to node <${name}> failed: ${err}`)
515
- throw new Error(`external request to node <${name}> failed: ${err}`)
536
+ await Promise.race<void>([
537
+ foundNode.receiveRequest(args),
538
+ new Promise((resolve, reject) => setTimeout(() =>
539
+ reject(new Error("timeout")), 10 * 1000))
540
+ ]).catch((err: Error) => {
541
+ cli!.log("warning", `external request to node <${name}> failed: ${err.message}`)
516
542
  })
517
543
  }
518
544
  }
@@ -621,7 +647,7 @@ type wsPeerInfo = {
621
647
  }
622
648
  })
623
649
  await hapi.start()
624
- cli!.log("info", `HAPI: started REST/WebSocket network service: http://${args.address}:${args.port}`)
650
+ cli!.log("info", `HAPI: started REST/WebSocket network service: http://${args.a}:${args.p}`)
625
651
 
626
652
  /* hook for sendResponse method of nodes */
627
653
  for (const node of graphNodes) {
@@ -629,7 +655,8 @@ type wsPeerInfo = {
629
655
  const data = JSON.stringify({ response: "NOTIFY", node: node.id, args })
630
656
  for (const [ peer, info ] of wsPeers.entries()) {
631
657
  cli!.log("info", `HAPI: peer ${peer}: ${data}`)
632
- info.ws.send(data)
658
+ if (info.ws.readyState === WebSocket.OPEN)
659
+ info.ws.send(data)
633
660
  }
634
661
  })
635
662
  }
@@ -651,9 +678,39 @@ type wsPeerInfo = {
651
678
  cli!.log("warning", `**** received signal ${signal} -- shutting down service ****`)
652
679
 
653
680
  /* shutdown HAPI service */
654
- cli!.log("info", `HAPI: stopping REST/WebSocket network service: http://${args.address}:${args.port}`)
681
+ cli!.log("info", `HAPI: stopping REST/WebSocket network service: http://${args.a}:${args.p}`)
655
682
  await hapi.stop({ timeout: 2000 })
656
683
 
684
+ /* clear WebSocket connections */
685
+ if (wsPeers.size > 0) {
686
+ cli!.log("info", "HAPI: closing WebSocket connections")
687
+ const closePromises: Promise<void>[] = []
688
+ for (const [ peer, info ] of wsPeers.entries()) {
689
+ closePromises.push(new Promise<void>((resolve, reject) => {
690
+ if (info.ws.readyState !== WebSocket.OPEN)
691
+ resolve()
692
+ else {
693
+ const timeout = setTimeout(() => {
694
+ reject(new Error(`timeout for peer ${peer}`))
695
+ }, 2 * 1000)
696
+ info.ws.once("close", () => {
697
+ clearTimeout(timeout)
698
+ resolve()
699
+ })
700
+ info.ws.close()
701
+ }
702
+ }))
703
+ }
704
+ await Promise.race([
705
+ Promise.all(closePromises),
706
+ new Promise((resolve, reject) =>
707
+ setTimeout(() => reject(new Error("timeout for all peers")), 5 * 1000))
708
+ ]).catch((err) => {
709
+ cli!.log("warning", `HAPI: WebSockets failed to close: ${err}`)
710
+ })
711
+ wsPeers.clear()
712
+ }
713
+
657
714
  /* graph processing: PASS 1: disconnect node streams */
658
715
  for (const node of graphNodes) {
659
716
  if (node.stream === null) {
@@ -683,8 +740,12 @@ type wsPeerInfo = {
683
740
  /* graph processing: PASS 2: close nodes */
684
741
  for (const node of graphNodes) {
685
742
  cli!.log("info", `close node <${node.id}>`)
686
- await node.close().catch((err) => {
687
- cli!.log("warning", `node <${node.id}> failed to close: ${err}`)
743
+ await Promise.race<void>([
744
+ node.close(),
745
+ new Promise((resolve, reject) => setTimeout(() =>
746
+ reject(new Error("timeout")), 10 * 1000))
747
+ ]).catch((err: Error) => {
748
+ cli!.log("warning", `node <${node.id}> failed to close: ${err.message}`)
688
749
  })
689
750
  }
690
751
 
@@ -703,6 +764,12 @@ type wsPeerInfo = {
703
764
  graphNodes.delete(node)
704
765
  }
705
766
 
767
+ /* clear event emitters */
768
+ finishEvents.removeAllListeners()
769
+
770
+ /* clear active nodes */
771
+ activeNodes.clear()
772
+
706
773
  /* terminate process */
707
774
  if (signal === "finished") {
708
775
  cli!.log("info", "terminate process (exit code 0)")
@@ -713,24 +780,38 @@ type wsPeerInfo = {
713
780
  process.exit(1)
714
781
  }
715
782
  }
783
+
784
+ /* hook into regular finish */
716
785
  finishEvents.on("finished", () => { shutdown("finished") })
786
+
787
+ /* hook into process signals */
717
788
  process.on("SIGINT", () => { shutdown("SIGINT") })
718
789
  process.on("SIGUSR1", () => { shutdown("SIGUSR1") })
719
790
  process.on("SIGUSR2", () => { shutdown("SIGUSR2") })
720
791
  process.on("SIGTERM", () => { shutdown("SIGTERM") })
792
+
793
+ /* re-hook into uncaught exception handler */
794
+ process.removeAllListeners("uncaughtException")
721
795
  process.on("uncaughtException", (err) => {
722
- cli!.log("error", `uncaught exception: ${err}`)
796
+ cli!.log("error", `uncaught exception: ${err.message}`)
723
797
  shutdown("exception")
724
798
  })
799
+
800
+ /* re-hook into unhandled promise rejection handler */
801
+ process.removeAllListeners("unhandledRejection")
725
802
  process.on("unhandledRejection", (reason) => {
726
- cli!.log("error", `unhandled rejection: ${reason}`)
803
+ if (reason instanceof Error)
804
+ cli!.log("error", `unhandled rejection: ${reason.message}`)
805
+ else
806
+ cli!.log("error", `unhandled rejection: ${reason}`)
727
807
  shutdown("exception")
728
808
  })
729
809
  })().catch((err: Error) => {
810
+ /* top-level exception handling */
730
811
  if (cli !== null)
731
- cli.log("error", err.message)
812
+ cli.log("error", `${err.message}:\n${err.stack}`)
732
813
  else
733
- process.stderr.write(`${pkg.name}: ${chalk.red("ERROR")}: ${err.message} ${err.stack}\n`)
814
+ process.stderr.write(`${pkg.name}: ${chalk.red("ERROR")}: ${err.message}\n${err.stack}\n`)
734
815
  process.exit(1)
735
816
  })
736
817