comq 0.12.0 → 0.14.0

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.
@@ -42,11 +42,11 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - name: Checkout repository
45
- uses: actions/checkout@v4
45
+ uses: actions/checkout@v6
46
46
 
47
47
  # Initializes the CodeQL tools for scanning.
48
48
  - name: Initialize CodeQL
49
- uses: github/codeql-action/init@v3
49
+ uses: github/codeql-action/init@v4
50
50
  with:
51
51
  languages: ${{ matrix.language }}
52
52
  # If you wish to specify custom queries, you can do so here or in a config file.
@@ -60,7 +60,7 @@ jobs:
60
60
  # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
61
61
  # If this step fails, then you should remove it and run the build manually (see below)
62
62
  - name: Autobuild
63
- uses: github/codeql-action/autobuild@v3
63
+ uses: github/codeql-action/autobuild@v4
64
64
 
65
65
  # ℹ️ Command-line programs to run using the OS shell.
66
66
  # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -73,6 +73,6 @@ jobs:
73
73
  # ./location_of_script_within_repo/buildscript.sh
74
74
 
75
75
  - name: Perform CodeQL Analysis
76
- uses: github/codeql-action/analyze@v3
76
+ uses: github/codeql-action/analyze@v4
77
77
  with:
78
78
  category: "/language:${{matrix.language}}"
@@ -1,73 +1,43 @@
1
1
  name: Release
2
2
 
3
3
  on:
4
- pull_request:
5
- types: [closed]
4
+ push:
6
5
  branches:
7
- - release
8
- - alpha
9
- paths:
10
- - 'source/**'
11
- - 'types/**'
12
- - 'package-lock.json'
6
+ - dev
13
7
 
14
- env:
15
- CI: 1
8
+ permissions:
9
+ contents: write
10
+ issues: write
11
+ pull-requests: write
12
+ id-token: write
16
13
 
17
14
  jobs:
18
- publish:
19
- if: github.event.pull_request.merged == true
15
+ release:
20
16
  runs-on: ubuntu-latest
17
+ env:
18
+ HUSKY: 0
21
19
 
22
20
  steps:
23
- - name: Setup Node
24
- uses: actions/setup-node@v4
25
- with:
26
- node-version: 20
27
- registry-url: 'https://registry.npmjs.org'
28
-
29
21
  - name: Checkout
30
- uses: actions/checkout@v4
22
+ uses: actions/checkout@v6
31
23
  with:
32
24
  fetch-depth: 0
25
+ persist-credentials: false
33
26
 
34
- - name: Run tests
35
- run: npm i && npm test
36
-
37
- - name: Import GPG key
38
- id: gpg
39
- uses: crazy-max/ghaction-import-gpg@v6
27
+ - name: Setup Node
28
+ uses: actions/setup-node@v6
40
29
  with:
41
- gpg_private_key: ${{ secrets.TOABOT_GPG_PRIVATE_KEY }}
42
- passphrase: ${{ secrets.TOABOT_GPG_PASSPHRASE }}
43
- git_user_signingkey: true
44
- git_commit_gpgsign: true
30
+ node-version: "24"
31
+ registry-url: https://registry.npmjs.org
32
+ package-manager-cache: false
45
33
 
46
- - name: Bump version
47
- run: |
48
- if [ ${{ github.base_ref }} = release ]; then
49
- npx lerna@7.3.0 version --conventional-graduate --no-commit-hooks --no-changelog --yes --message "ci: %s" --sign-git-commit --sign-git-tag
50
- else
51
- npx lerna@7.3.0 version --conventional-prerelease --preid alpha --dist-tag alpha --no-commit-hooks --no-changelog --yes --message "ci: %s" --git-tag-command "echo %s" --sign-git-commit
52
- fi
53
-
54
- git fetch
55
- git checkout dev
56
- git merge ${{ github.base_ref }}
57
- git push --all origin --no-verify
58
- env:
59
- GITHUB_TOKEN: ${{ secrets.TOABOT_GITHUB_TOKEN }}
60
- GIT_AUTHOR_NAME: ${{ steps.gpg.outputs.name }}
61
- GIT_AUTHOR_EMAIL: ${{ steps.gpg.outputs.email }}
62
- GIT_COMMITTER_NAME: ${{ steps.gpg.outputs.name }}
63
- GIT_COMMITTER_EMAIL: ${{ steps.gpg.outputs.email }}
34
+ - name: Install dependencies
35
+ run: npm ci
36
+
37
+ - name: Run tests
38
+ run: npm test
64
39
 
65
- - name: Publish packages
40
+ - name: Semantic release
66
41
  env:
67
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
68
- run: |
69
- if [ ${{ github.base_ref }} = release ]; then
70
- npm publish
71
- else
72
- npm publish --tag alpha
73
- fi
42
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+ run: npx semantic-release
package/.releaserc ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "branches": [
3
+ "dev"
4
+ ],
5
+ "plugins": [
6
+ "@semantic-release/commit-analyzer",
7
+ "@semantic-release/release-notes-generator",
8
+ [
9
+ "@semantic-release/changelog",
10
+ {
11
+ "changelogFile": "CHANGELOG.md"
12
+ }
13
+ ],
14
+ [
15
+ "@semantic-release/npm"
16
+ ],
17
+ [
18
+ "@semantic-release/git",
19
+ {
20
+ "assets": [
21
+ "package.json",
22
+ "package-lock.json",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
26
+ }
27
+ ],
28
+ [
29
+ "@semantic-release/github",
30
+ {
31
+ "owner": "toa-io"
32
+ }
33
+ ]
34
+ ]
35
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,35 @@
1
+ # AGENTS.md
2
+
3
+ ## Cursor Cloud specific instructions
4
+
5
+ ### Project Overview
6
+
7
+ ComQ is a Node.js communication library over AMQP (RabbitMQ). It provides RPC, pub/sub, task queues, pipelines, and sharded connections.
8
+
9
+ ### Services
10
+
11
+ Feature tests start two RabbitMQ instances automatically via Testcontainers (`features/steps/brokers.js`). Credentials: `developer` / `secret`. AMQP ports are assigned dynamically on the host.
12
+
13
+ ### Docker
14
+
15
+ Feature tests require a running Docker daemon. In nested container environments, start Docker with:
16
+
17
+ ```bash
18
+ sudo dockerd --exec-opt native.cgroupdriver=cgroupfs &
19
+ sudo chmod 666 /var/run/docker.sock
20
+ ```
21
+
22
+ Do not use `docker compose up` for feature tests — Testcontainers manages broker lifecycle. `docker compose` may fail here because `deploy.resources.limits.memory` requires cgroupv2 memory limits that are unavailable in nested setups.
23
+
24
+ ### Commands
25
+
26
+ - `npm test` — runs `standard` (lint) + `jest` (300 unit tests, no RabbitMQ required)
27
+ - `npm run features` — runs all Cucumber feature tests (starts RabbitMQ via Testcontainers)
28
+ - `npm run lint` — runs `standard --fix --verbose | snazzy`
29
+
30
+ ### Testing Notes
31
+
32
+ - Unit tests (`jest`) use mocks and do NOT require running RabbitMQ.
33
+ - Feature tests (`cucumber-js`) start brokers in `BeforeAll` and stop them in `AfterAll`.
34
+ - Recovery/connection tests (in `features/connection.feature`, `features/recovery.feature`, `features/shards.feature`) stop/kill broker containers and take 5-15 minutes to complete.
35
+ - To run only fast feature tests (no broker stop/start): `npx cucumber-js --fail-fast -t 'not @manual' features/events.feature features/rpc.feature features/tasks.feature features/singleton.feature features/properties.feature features/tasks.shards.feature`
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # [0.14.0](https://github.com/toa-io/comq/compare/v0.13.0...v0.14.0) (2026-05-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * **features:** manage RabbitMQ brokers with Testcontainers ([1d9ca59](https://github.com/toa-io/comq/commit/1d9ca591ff458c75caf87ab63224b15942ba7d07))
7
+ * **features:** use Testcontainers for RabbitMQ in Gherkin tests ([#219](https://github.com/toa-io/comq/issues/219)) ([a33e66b](https://github.com/toa-io/comq/commit/a33e66bfc357f0f089c728bc60c08f6f24b2c8d1))
8
+
9
+ # Changelog
10
+
11
+ All notable changes to this project are documented in this file by [semantic-release](https://github.com/semantic-release/semantic-release).
@@ -1,7 +1,7 @@
1
1
  import { connect } from 'comq'
2
2
  import { url } from '../const.js'
3
3
 
4
- const limit = Number(process.argv[2]) || Math.round(Math.random() * 8 + 2)
4
+ const limit = Number(process.argv[2]) ?? Math.round(Math.random() * 8 + 2)
5
5
 
6
6
  let io
7
7
  let stream
@@ -19,9 +19,9 @@ async function run () {
19
19
  }
20
20
 
21
21
  async function fetch () {
22
- console.log(`Fetching stream with ${limit} numbers`)
22
+ console.log(`Fetching stream with ${limit === 0 ? 'infinite' : limit} numbers`)
23
23
 
24
- stream = await io.fetch('get_numbers', { limit })
24
+ stream = await io.request('get_numbers', { limit })
25
25
 
26
26
  for await (const number of stream) console.log('Received:', number)
27
27
 
@@ -18,6 +18,8 @@ async function run () {
18
18
  async function * produce ({ limit }) {
19
19
  console.log('Request received with limit:', limit)
20
20
 
21
+ if (limit === 0) limit = Infinity
22
+
21
23
  for (let i = 0; i < limit; i++) {
22
24
  yield i
23
25
 
@@ -8,5 +8,4 @@ Feature: Back pressure handling
8
8
  """
9
9
  () => { return Buffer.from('ok') }
10
10
  """
11
- When I'm sending 100kB requests to the `flood` queue at 5kHz for 0.5 seconds
12
- Then back pressure was applied
11
+ When I'm flooding the `flood` queue until back pressure is applied
@@ -1,4 +1,11 @@
1
1
  # Features
2
2
 
3
- To run features, start the RabbitMQ server by running `docker compose up -d` in the package root,
4
- then execute `npm run features`.
3
+ Feature tests start two RabbitMQ instances automatically via [Testcontainers](https://node.testcontainers.org/modules/rabbitmq/).
4
+
5
+ Run them with:
6
+
7
+ ```bash
8
+ npm run features
9
+ ```
10
+
11
+ Docker must be available on the host. No manual `docker compose` setup is required.
@@ -41,7 +41,7 @@ Feature: Sharded Connection
41
41
  Scenario: Shard crashes while publishing events
42
42
  Given an active sharded connection
43
43
  And events are exclusively consumed from the `flood` exchange
44
- And I'm publishing 1kB events to the `flood` exchange at 100Hz
44
+ And I'm publishing 1kB events to the `flood` exchange at 10Hz
45
45
  When one of the brokers has crashed
46
46
  Then no exceptions are thrown
47
47
  And all events have been received
@@ -4,43 +4,27 @@ const assert = require('node:assert')
4
4
  const { randomBytes } = require('node:crypto')
5
5
  const { quantity, timeout } = require('@toa.io/generic')
6
6
 
7
- const { When, Then } = require('@cucumber/cucumber')
7
+ const { When } = require('@cucumber/cucumber')
8
8
 
9
- When('I\'m sending {quantity}B requests to the {token} queue at {quantity}Hz for {number} second(s)',
9
+ const PAYLOAD = quantity('500k')
10
+ const BATCH = 20
11
+ const DEADLINE = 25_000
12
+
13
+ When('I\'m flooding the {token} queue until back pressure is applied',
10
14
  /**
11
- * @param {string} bytesQ
12
15
  * @param {string} queue
13
- * @param {string} frequencyQ
14
- * @param {number} seconds
15
16
  * @this {comq.features.Context}
16
17
  */
17
- async function (bytesQ, queue, frequencyQ, seconds) {
18
- const bytes = quantity(bytesQ)
19
- const frequency = quantity(frequencyQ)
20
- const buffer = randomBytes(bytes)
21
- const times = seconds * frequency
22
-
23
- // intervals less than 1ms are not accurate
24
- const interval = Math.max((1000 / frequency), 1)
25
- const each = Math.max(1 / (1000 / frequency), 1)
26
-
27
- const promises = []
18
+ async function (queue) {
19
+ const buffer = randomBytes(PAYLOAD)
20
+ const deadline = Date.now() + DEADLINE
21
+ const pending = []
28
22
 
29
- for (let i = 0; i < times; i++) {
30
- const promise = this.io.request(queue, buffer)
23
+ while (!this.events.flow && Date.now() < deadline) {
24
+ for (let i = 0; i < BATCH; i++) { pending.push(this.io.request(queue, buffer).catch(() => {})) }
31
25
 
32
- promises.push(promise)
33
-
34
- if ((i + 1) % each === 0) await timeout(interval)
26
+ await timeout(0)
35
27
  }
36
28
 
37
- await Promise.all(promises)
38
- })
39
-
40
- Then('back pressure was applied',
41
- /**
42
- * @this {comq.features.Context}
43
- */
44
- function () {
45
29
  assert.equal(this.events.flow, true, 'Back pressure hasn\'t been applied')
46
30
  })
@@ -1,13 +1,13 @@
1
1
  'use strict'
2
2
 
3
- const { timeout, random } = require('@toa.io/generic')
4
- const { execute } = require('@toa.io/command')
3
+ const { random } = require('@toa.io/generic')
5
4
 
6
5
  const { Given } = require('@cucumber/cucumber')
6
+ const { BROKERS_AMOUNT, actions } = require('./brokers')
7
7
 
8
8
  Given('the broker is/has {status}',
9
9
  /**
10
- * @param {'up' | 'down'} status
10
+ * @param {'up' | 'down' | 'crashed'} status
11
11
  * @this {comq.features.Context}
12
12
  */
13
13
  async function (status) {
@@ -18,7 +18,7 @@ Given('the broker is/has {status}',
18
18
 
19
19
  Given('one of the brokers is/has {status}',
20
20
  /**
21
- * @param {'up' | 'down'} status
21
+ * @param {'up' | 'down' | 'crashed'} status
22
22
  * @this {comq.features.Context}
23
23
  */
24
24
  async function (status) {
@@ -28,29 +28,3 @@ Given('one of the brokers is/has {status}',
28
28
 
29
29
  await actions[status](shard)
30
30
  })
31
-
32
- const actions = {
33
- up: async (n = 0) => {
34
- await execute('docker start comq-rmq-' + n)
35
- await healthy(n)
36
- },
37
- down: async (n = 0) => {
38
- await execute('docker stop comq-rmq-' + n)
39
- },
40
- crashed: async (n = 0) => {
41
- await execute('docker kill comq-rmq-' + n)
42
- }
43
- }
44
-
45
- async function healthy (n) {
46
- let process
47
-
48
- do {
49
- await timeout(HEALTHCHECK_INTERVAL)
50
-
51
- process = await execute('docker inspect -f {{.State.Health.Status}} comq-rmq-' + n)
52
- } while (process.output !== 'healthy')
53
- }
54
-
55
- const BROKERS_AMOUNT = 2
56
- const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ const { execFile } = require('node:child_process')
4
+ const { promisify } = require('node:util')
5
+ const { timeout } = require('@toa.io/generic')
6
+ const { RabbitMQContainer } = require('@testcontainers/rabbitmq')
7
+ const { getContainerRuntimeClient } = require('testcontainers')
8
+
9
+ const docker = promisify(execFile)
10
+
11
+ const IMAGE = 'rabbitmq:3.10.0-management'
12
+ const USER = 'developer'
13
+ const PASSWORD = 'secret'
14
+ const AMQP_PORT = 5672
15
+ const BROKERS_AMOUNT = 2
16
+ const HOST_PORTS = [5673, 5674]
17
+ const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
18
+
19
+ /** @type {import('@testcontainers/rabbitmq').StartedRabbitMQContainer[]} */
20
+ const brokers = []
21
+
22
+ async function startBrokers () {
23
+ if (brokers.length > 0) return
24
+
25
+ for (let n = 0; n < BROKERS_AMOUNT; n++) await removeStale(`comq-rmq-${n}`)
26
+
27
+ const started = await Promise.all(
28
+ HOST_PORTS.map((hostPort, n) =>
29
+ new RabbitMQContainer(IMAGE)
30
+ .withEnvironment({
31
+ RABBITMQ_DEFAULT_USER: USER,
32
+ RABBITMQ_DEFAULT_PASS: PASSWORD
33
+ })
34
+ .withAutoRemove(false)
35
+ .withName(`comq-rmq-${n}`)
36
+ .withExposedPorts({ container: AMQP_PORT, host: hostPort })
37
+ .withHealthCheck({
38
+ test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'],
39
+ interval: 5000,
40
+ timeout: 1000,
41
+ retries: 5
42
+ })
43
+ .start()
44
+ )
45
+ )
46
+
47
+ brokers.push(...started)
48
+
49
+ await Promise.all(
50
+ Array.from({ length: BROKERS_AMOUNT }, (_, n) => healthy(n))
51
+ )
52
+ }
53
+
54
+ async function stopBrokers () {
55
+ await Promise.all(brokers.map((broker) => broker.stop({ remove: true })))
56
+ brokers.length = 0
57
+ }
58
+
59
+ /**
60
+ * @param {string} name
61
+ */
62
+ async function removeStale (name) {
63
+ const client = await getContainerRuntimeClient()
64
+ const listed = await client.container.list()
65
+
66
+ for (const info of listed) {
67
+ if (!info.Names.some((entry) => entry === `/${name}`)) continue
68
+
69
+ const container = client.container.getById(info.Id)
70
+
71
+ try {
72
+ await client.container.stop(container, { timeout: 0 })
73
+ } catch {
74
+ // already stopped
75
+ }
76
+
77
+ await container.remove({ force: true })
78
+ }
79
+ }
80
+
81
+ /**
82
+ * @param {number} [n]
83
+ * @returns {string}
84
+ */
85
+ function getAddress (n = 0) {
86
+ if (brokers[n] === undefined) throw new Error(`Broker ${n} is not started`)
87
+
88
+ return `localhost:${HOST_PORTS[n]}`
89
+ }
90
+
91
+ /**
92
+ * @param {number} [n]
93
+ */
94
+ async function healthy (n = 0) {
95
+ do {
96
+ await timeout(HEALTHCHECK_INTERVAL)
97
+
98
+ const { stdout } = await docker('docker', ['inspect', '-f', '{{.State.Health.Status}}', `comq-rmq-${n}`])
99
+
100
+ if (stdout.trim() === 'healthy') return
101
+ } while (true)
102
+ }
103
+
104
+ const actions = {
105
+ up: async (n = 0) => {
106
+ await docker('docker', ['start', `comq-rmq-${n}`])
107
+ await healthy(n)
108
+ },
109
+ down: async (n = 0) => {
110
+ await docker('docker', ['stop', `comq-rmq-${n}`])
111
+ },
112
+ crashed: async (n = 0) => {
113
+ await docker('docker', ['kill', `comq-rmq-${n}`])
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ BROKERS_AMOUNT,
119
+ USER,
120
+ PASSWORD,
121
+ startBrokers,
122
+ stopBrokers,
123
+ getAddress,
124
+ actions
125
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { World } = require('@cucumber/cucumber')
4
4
  const { connect, assert } = require('../../')
5
+ const { getAddress, USER, PASSWORD } = require('./brokers')
5
6
 
6
7
  /**
7
8
  * @implements {comq.features.Context}
@@ -88,14 +89,11 @@ class Context extends World {
88
89
  }
89
90
 
90
91
  #url (i, user, password) {
91
- return PROTOCOL + user + ':' + password + '@' + SHARDS[i]
92
+ return PROTOCOL + user + ':' + password + '@' + getAddress(i)
92
93
  }
93
94
  }
94
95
 
95
96
  const PROTOCOL = 'amqp://'
96
- const SHARDS = ['localhost:5673', 'localhost:5674']
97
- const USER = 'developer'
98
- const PASSWORD = 'secret'
99
97
 
100
98
  /** @type {comq.diagnostics.Event[]} */
101
99
  const EVENTS = ['open', 'close', 'flow', 'discard']
@@ -1,11 +1,24 @@
1
1
  'use strict'
2
2
 
3
- const { After } = require('@cucumber/cucumber')
3
+ const { BeforeAll, After, AfterAll } = require('@cucumber/cucumber')
4
+ const { startBrokers, stopBrokers, actions, BROKERS_AMOUNT } = require('./brokers')
5
+
6
+ BeforeAll({ timeout: 120_000 }, async function () {
7
+ await startBrokers()
8
+ })
4
9
 
5
10
  After(
6
11
  /**
7
12
  * @this {comq.features.Context}
8
13
  */
9
14
  async function () {
15
+ await Promise.all(
16
+ Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
17
+ )
18
+
10
19
  await this.disconnect()
11
20
  })
21
+
22
+ AfterAll(async function () {
23
+ await stopBrokers()
24
+ })
package/fixplan.md ADDED
@@ -0,0 +1,507 @@
1
+ # comq — reconnection hang fix plan
2
+
3
+ This document describes the defects identified in the single-connection
4
+ reconnection path (`source/connection.js`, `source/channel.js`,
5
+ `source/attributes/failsafe.js`) and concrete, minimally-invasive fixes for
6
+ each one. Problems are ordered by their likelihood of producing the observed
7
+ symptom: *"after RabbitMQ connection is lost, the application is sometimes
8
+ never recovered."*
9
+
10
+ ---
11
+
12
+ ## Problem 1 — Deadlock in `Channel.recover()` via `recall()` + `failsafe`
13
+
14
+ ### Symptom
15
+
16
+ During reconnect, `Channel.recover()` rebuilds the AMQP channel and then
17
+ replays previously recorded `consume` / `subscribe` calls via `recall(this)`.
18
+ `#recovery` is resolved only *after* `recall(this)` completes:
19
+
20
+ ```142:160:source/channel.js
21
+ async recover (connection) {
22
+ this.#connection = connection
23
+
24
+ await this.create()
25
+
26
+ lazy.reset(this)
27
+ await recall(this)
28
+
29
+ this.#unpause(INTERRUPTION)
30
+
31
+ for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
32
+
33
+ // handle interruptions
34
+ await new Promise(resolve => setTimeout(resolve, 0))
35
+
36
+ this.#recovery.resolve()
37
+ this.#recovery = new Promex()
38
+ this.#diagnostics.emit('recover')
39
+ }
40
+ ```
41
+
42
+ The replayed methods are wrapped in `failsafe(this, this.#recover, ...)`. If
43
+ any replayed operation throws a *non-permanent* exception (`Channel closed`,
44
+ `Channel ended, no reply will be forthcoming`, or the internal
45
+ `INTERRUPTION`), `failsafe` calls:
46
+
47
+ ```333:336:source/channel.js
48
+ async #recover (exception) {
49
+ if (permanent(exception)) return false
50
+ else await this.#recovery
51
+ }
52
+ ```
53
+
54
+ `failsafe` now awaits `this.#recovery`, which is the same Promex the enclosing
55
+ `recover()` is supposed to resolve further down. `recall(this)` cannot
56
+ finish, so `#recovery` is never resolved — the channel is stuck, and every
57
+ `send`/`publish`/`consume` parked on `failsafe → #recovery` hangs **forever**.
58
+
59
+ This reliably happens when the freshly created channel dies mid-setup, which
60
+ is common after a broker restart while heartbeats are still flapping, or
61
+ when `#assertQueue`/`#assertExchange` hits a transient amqplib "Channel
62
+ closed" race.
63
+
64
+ ### Fix
65
+
66
+ Add a reentrancy guard so that `failsafe.#recover` refuses to wait on
67
+ `#recovery` while `recover()` is itself running — it must propagate instead,
68
+ letting `recall()` surface the failure to `Connection.#open`, which (after
69
+ Problem 2 is fixed) will ask `retry()` for another reopen attempt.
70
+
71
+ ```js
72
+ // source/channel.js
73
+ #recovering = false
74
+
75
+ async recover (connection) {
76
+ this.#recovering = true
77
+ try {
78
+ this.#connection = connection
79
+ await this.create()
80
+ lazy.reset(this)
81
+ await recall(this)
82
+ this.#unpause(INTERRUPTION)
83
+ for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
84
+ await new Promise(resolve => setTimeout(resolve, 0))
85
+ this.#recovery.resolve()
86
+ this.#recovery = new Promex()
87
+ this.#diagnostics.emit('recover')
88
+ } finally {
89
+ this.#recovering = false
90
+ }
91
+ }
92
+
93
+ async #recover (exception) {
94
+ if (permanent(exception)) return false
95
+ if (this.#recovering) return false // never wait on #recovery from inside recover()
96
+ await this.#recovery
97
+ }
98
+ ```
99
+
100
+ Alternative: wrap `recall(this)` in try/catch inside `recover()`, and on
101
+ failure, reject the *old* `#recovery` (with a sentinel), create a fresh one,
102
+ and rethrow. The reentrancy guard is simpler and strictly local.
103
+
104
+ ### Test
105
+
106
+ Add a unit test in `test/channel.test.js` that:
107
+
108
+ 1. Creates a channel, records a `consume` call.
109
+ 2. Triggers `recover()` but makes the mocked `channel.consume` throw a
110
+ `new Error('Channel closed')` on the replay.
111
+ 3. Asserts `recover()` rejects (rather than hanging) within a short timeout.
112
+
113
+ ---
114
+
115
+ ## Problem 2 — `channel.recover()` errors escape the `retry()` loop in `Connection.#open`
116
+
117
+ ### Symptom
118
+
119
+ Only `amqp.connect` is guarded by try/catch in `#open`; channel recovery
120
+ runs *after* the catch block:
121
+
122
+ ```73:92:source/connection.js
123
+ #open = async (retry) => {
124
+ try {
125
+ this.#connection = await amqp.connect(this.#url)
126
+ } catch (exception) {
127
+ if (this.#transient(exception)) return retry
128
+ else throw exception
129
+ }
130
+
131
+ // This prevents the process from crashing; 'close' will be emitted next.
132
+ // https://amqp-node.github.io/amqplib/channel_api.html#model_events
133
+ this.#connection.on('error', noop)
134
+
135
+ this.#connection.on('close', this.#close)
136
+ this.#diagnostics.emit('open')
137
+
138
+ for (const channel of this.#channels) await channel.recover(this.#connection)
139
+
140
+ this.#recovery.resolve()
141
+ this.#recovery = new Promex()
142
+ }
143
+ ```
144
+
145
+ If any `channel.recover()` throws (permanent topology error such as
146
+ `PRECONDITION_FAILED`, or the freshly opened connection dies before channel
147
+ creation completes, or the deadlock from Problem 1 is broken by rejection),
148
+ `#open` rejects → `retry()` rethrows → `open()` rejects. And `open()` was
149
+ called from:
150
+
151
+ ```97:103:source/connection.js
152
+ #close = async (error) => {
153
+ this.#diagnostics.emit('close', error)
154
+ this.#connection.removeAllListeners()
155
+ this.#connection = undefined
156
+
157
+ if (error !== undefined) await this.open()
158
+ }
159
+ ```
160
+
161
+ …which has no error handler — rejection becomes an `unhandledRejection`, and
162
+ the Connection stops trying to reconnect. Whether the broken amqplib
163
+ `Connection` eventually emits another `close` to re-arm the flow is not
164
+ guaranteed (TCP half-open, missing heartbeat). The process ends up with a
165
+ live event loop but no broker connectivity.
166
+
167
+ ### Fix
168
+
169
+ Keep channel recovery inside the `retry()` contract: on any failure, close
170
+ the half-baked connection and ask `retry` to try again.
171
+
172
+ ```js
173
+ #open = async (retry) => {
174
+ try {
175
+ this.#connection = await amqp.connect(this.#url)
176
+ } catch (exception) {
177
+ if (this.#transient(exception)) return retry
178
+ else throw exception
179
+ }
180
+
181
+ this.#connection.on('error', noop)
182
+ this.#connection.on('close', this.#close)
183
+ this.#diagnostics.emit('open')
184
+
185
+ try {
186
+ for (const channel of this.#channels) {
187
+ await channel.recover(this.#connection)
188
+ }
189
+ } catch (exception) {
190
+ this.#diagnostics.emit('error', exception)
191
+ // prevent close re-entry from this broken connection
192
+ this.#connection.removeAllListeners()
193
+ try { await this.#connection.close() } catch {}
194
+ this.#connection = undefined
195
+ return retry
196
+ }
197
+
198
+ this.#recovery.resolve()
199
+ this.#recovery = new Promex()
200
+ }
201
+ ```
202
+
203
+ This keeps `retry()` in charge, honours its backoff, and never surfaces an
204
+ unhandled rejection to `#close`.
205
+
206
+ ### Test
207
+
208
+ In `test/connection.test.js`, mock `channel.recover` to throw once, then
209
+ succeed; assert `amqplib.connect` is called at least twice and `createChannel`
210
+ eventually resolves.
211
+
212
+ ---
213
+
214
+ ## Problem 3 — Unhandled rejection from `#close`
215
+
216
+ ### Symptom
217
+
218
+ `#close` is an event handler attached to the amqplib `Connection`'s `close`
219
+ event. It `await this.open()` with no surrounding `try/catch`. If `open()`
220
+ rejects (see Problem 2), the rejection becomes an `unhandledRejection`. On
221
+ processes that exit on unhandled rejections, this kills the app; on others,
222
+ it silently leaves the library without a working connection.
223
+
224
+ ### Fix
225
+
226
+ Detach lifecycle from the event emitter so that the rejection is either
227
+ swallowed on purpose (with diagnostics) or restarted:
228
+
229
+ ```js
230
+ #close = (error) => {
231
+ this.#diagnostics.emit('close', error)
232
+ this.#connection.removeAllListeners()
233
+ this.#connection = undefined
234
+
235
+ if (error !== undefined) {
236
+ this.open().catch((exception) => this.#diagnostics.emit('error', exception))
237
+ }
238
+ }
239
+ ```
240
+
241
+ Users that observe diagnostics get a signal (the existing `close` diagnostic
242
+ plus a new `error` diagnostic) without the process being destabilised.
243
+
244
+ ### Test
245
+
246
+ Subscribe to the `error` diagnostic, force `open()` to reject, emit `close`
247
+ with an error on the mocked amqp connection, assert the diagnostic fires and
248
+ the process does not register an unhandled rejection.
249
+
250
+ ---
251
+
252
+ ## Problem 4 — `#transient` is too narrow on the initial connect
253
+
254
+ ### Symptom
255
+
256
+ ```109:114:source/connection.js
257
+ #transient (exception) {
258
+ const abruptly = exception.message === 'Socket closed abruptly during opening handshake'
259
+ const tls = exception.message === 'Client network socket disconnected before secure TLS connection was established'
260
+
261
+ return this.#running || abruptly || tls
262
+ }
263
+ ```
264
+
265
+ Before `#running` flips to `true` (i.e. on the *first* `open()`), only two
266
+ exact error messages are considered transient. Everyday transient failures
267
+ (`ECONNREFUSED`, `EAI_AGAIN`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNRESET`) cause
268
+ the boot to throw instead of retrying, forcing process supervisors to do the
269
+ retry. After first connect, `#running` is `true` forever, so *everything*
270
+ becomes transient — the asymmetry between "before first open" and "after
271
+ first open" is surprising.
272
+
273
+ ### Fix
274
+
275
+ Introduce an explicit whitelist of transient failures that applies in both
276
+ phases:
277
+
278
+ ```js
279
+ #transient (exception) {
280
+ const transientCodes = new Set([
281
+ 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET',
282
+ 'EHOSTUNREACH', 'ENETUNREACH'
283
+ ])
284
+ const transientMessages = new Set([
285
+ 'Socket closed abruptly during opening handshake',
286
+ 'Client network socket disconnected before secure TLS connection was established'
287
+ ])
288
+
289
+ if (this.#running) return true
290
+ if (transientCodes.has(exception.code)) return true
291
+ if (transientMessages.has(exception.message)) return true
292
+ return false
293
+ }
294
+ ```
295
+
296
+ Optionally gate the `#running` shortcut behind a maximum attempt count or a
297
+ "permanent" classifier for things like `ACCESS_REFUSED`, so a misconfigured
298
+ credential doesn't retry forever — but that is a separate behaviour change
299
+ and should be opt-in.
300
+
301
+ ### Test
302
+
303
+ Parametrised test in `test/connection.test.js` for each listed error code,
304
+ asserting `open()` resolves after one failing `amqplib.connect`.
305
+
306
+ ---
307
+
308
+ ## Problem 5 — Reentrant `#close` can run two overlapping `#open` loops
309
+
310
+ ### Symptom
311
+
312
+ If the new connection dies *while* `#open` is still iterating
313
+ `channel.recover()`, amqplib fires `close` on that connection. The handler
314
+ executes:
315
+
316
+ ```js
317
+ this.#connection.removeAllListeners()
318
+ this.#connection = undefined
319
+ if (error !== undefined) await this.open()
320
+ ```
321
+
322
+ While the outer `#open` is still running and will, on its next loop
323
+ iteration, pass the now-`undefined` `this.#connection` to `channel.recover`
324
+ (crash → see Problem 2). You now have two concurrent `open()` retry loops
325
+ racing; whichever one wins may leave the other with a live amqp connection
326
+ that's unreferenced.
327
+
328
+ ### Fix
329
+
330
+ Serialise `open()` with a single in-flight promise, and stop reading
331
+ `this.#connection` as a shared variable mid-loop:
332
+
333
+ ```js
334
+ /** @type {Promise<void> | null} */
335
+ #opening = null
336
+
337
+ async open () {
338
+ if (this.#opening !== null) return this.#opening
339
+ this.#opening = retry(this.#open).finally(() => { this.#opening = null })
340
+ await this.#opening
341
+ this.#running = true
342
+ }
343
+
344
+ #open = async (retry) => {
345
+ let connection
346
+ try {
347
+ connection = await amqp.connect(this.#url)
348
+ } catch (exception) {
349
+ if (this.#transient(exception)) return retry
350
+ else throw exception
351
+ }
352
+
353
+ connection.on('error', noop)
354
+ connection.on('close', this.#close)
355
+ this.#connection = connection
356
+ this.#diagnostics.emit('open')
357
+
358
+ try {
359
+ for (const channel of this.#channels) await channel.recover(connection)
360
+ } catch (exception) {
361
+ this.#diagnostics.emit('error', exception)
362
+ connection.removeAllListeners()
363
+ try { await connection.close() } catch {}
364
+ if (this.#connection === connection) this.#connection = undefined
365
+ return retry
366
+ }
367
+
368
+ this.#recovery.resolve()
369
+ this.#recovery = new Promex()
370
+ }
371
+ ```
372
+
373
+ Two wins:
374
+
375
+ 1. Channel recovery uses a local `connection` reference, so a concurrent
376
+ `#close` can't swap it out mid-loop.
377
+ 2. Concurrent `open()` calls converge on a single retry loop.
378
+
379
+ ### Test
380
+
381
+ Simulate two `close` events in quick succession (before the first recovery
382
+ finishes) and assert only one `amqp.connect` is *in progress* at any time and
383
+ the final state has exactly one live connection.
384
+
385
+ ---
386
+
387
+ ## Problem 6 — Partial `Channel.recover()` leaves the channel wedged
388
+
389
+ ### Symptom
390
+
391
+ If `recover()` throws between `await this.create()` and the final
392
+ `#recovery.resolve()`, the Channel has already:
393
+
394
+ - replaced `this.#channel` with a new one,
395
+ - reset lazy locks,
396
+ - possibly pushed new consumer tags,
397
+
398
+ but has **not**:
399
+
400
+ - rejected pending confirmations,
401
+ - unpaused `#paused`,
402
+ - resolved `#recovery`.
403
+
404
+ Every user operation currently parked on `#recovery` stays parked forever
405
+ unless a subsequent recover succeeds. Combined with Problem 1, this is the
406
+ mechanism by which single failures persist.
407
+
408
+ ### Fix
409
+
410
+ Make `recover()` structurally transactional — always release waiters on
411
+ failure by rejecting `#recovery` (with a sentinel that `failsafe.#recover`
412
+ classifies as permanent, so callers see a real error instead of hanging):
413
+
414
+ ```js
415
+ async recover (connection) {
416
+ this.#recovering = true
417
+ try {
418
+ this.#connection = connection
419
+ await this.create()
420
+ lazy.reset(this)
421
+ await recall(this)
422
+ this.#unpause(INTERRUPTION)
423
+ for (const confirmation of this.#confirmations) confirmation.reject(INTERRUPTION)
424
+ await new Promise(resolve => setTimeout(resolve, 0))
425
+ this.#recovery.resolve()
426
+ this.#recovery = new Promex()
427
+ this.#diagnostics.emit('recover')
428
+ } catch (exception) {
429
+ // release everyone parked on #recovery; they will propagate the error
430
+ this.#unpause(exception)
431
+ for (const confirmation of this.#confirmations) confirmation.reject(exception)
432
+ this.#recovery.reject(exception)
433
+ this.#recovery = new Promex()
434
+ throw exception
435
+ } finally {
436
+ this.#recovering = false
437
+ }
438
+ }
439
+ ```
440
+
441
+ Combined with Problem 2's fix, the Connection retry loop will catch this,
442
+ close the broken connection, and retry — instead of wedging callers.
443
+
444
+ ### Test
445
+
446
+ Force `create()` to throw, call `recover()`, assert that a previously
447
+ `await`-ing `send()` rejects (rather than hanging) with the propagated
448
+ exception.
449
+
450
+ ---
451
+
452
+ ## Problem 7 — No liveness/heartbeat watchdog
453
+
454
+ ### Symptom (optional but recommended)
455
+
456
+ amqplib's `Connection` relies on AMQP heartbeats and the underlying TCP
457
+ socket. In the half-open TCP case (NAT drop, cloud NLB idle timeout,
458
+ broker OOM without RST), neither `close` nor `error` may be emitted for a
459
+ long time — enough to look "stuck" even though the retry machinery is
460
+ healthy.
461
+
462
+ ### Fix
463
+
464
+ Add a watchdog that forcibly destroys the underlying amqp `Connection` if no
465
+ progress has been observed within a configurable window. The simplest
466
+ implementation:
467
+
468
+ ```js
469
+ // source/connection.js
470
+ #heartbeatTimer = null
471
+
472
+ #armWatchdog (connection, timeoutMs = 60_000) {
473
+ const reset = () => {
474
+ clearTimeout(this.#heartbeatTimer)
475
+ this.#heartbeatTimer = setTimeout(() => {
476
+ try { connection.connection?.stream?.destroy() } catch {}
477
+ }, timeoutMs)
478
+ }
479
+ reset()
480
+ connection.on('heartbeat', reset) // amqplib emits on each heartbeat
481
+ connection.on('close', () => clearTimeout(this.#heartbeatTimer))
482
+ }
483
+ ```
484
+
485
+ Call `#armWatchdog(this.#connection)` from `#open`. Any truly silent
486
+ connection will be destroyed, emitting `close`, and the normal retry loop
487
+ will take over.
488
+
489
+ ### Test
490
+
491
+ Integration test (can be skipped by default) that blocks RabbitMQ traffic
492
+ with `iptables` / network namespaces in CI, asserting the library recovers
493
+ within watchdog + retry window.
494
+
495
+ ---
496
+
497
+ ## Rollout order
498
+
499
+ 1. Problem 1 and Problem 2 together — they remove the deadlock and keep
500
+ recovery inside the retry loop. These are the minimum fix for the hang.
501
+ 2. Problem 3 — guard `#close`'s rejection.
502
+ 3. Problem 6 — transactional `Channel.recover()`.
503
+ 4. Problem 5 — serialise `open()` / local connection reference.
504
+ 5. Problem 4 — broaden transient classifier.
505
+ 6. Problem 7 — optional watchdog.
506
+
507
+ Each step is independently deployable and testable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -18,35 +18,39 @@
18
18
  "AMQP",
19
19
  "RabbitMQ"
20
20
  ],
21
- "engines": {
22
- "node": ">= 12.0.0"
23
- },
24
21
  "publishConfig": {
25
22
  "access": "public"
26
23
  },
27
24
  "main": "source/index.js",
28
25
  "types": "types/index.d.ts",
29
26
  "devDependencies": {
30
- "@cucumber/cucumber": "^10.0.1",
31
- "@toa.io/command": "^0.4.0",
32
- "@toa.io/generic": "^1.0.0-alpha.0",
33
- "@types/amqplib": "^0.10.3",
34
- "@types/jest": "^29.5.8",
35
- "husky": "^9.0.11",
36
- "jest": "^29.5.0",
37
- "js-yaml": "^4.1.0",
38
- "randomstring": "^1.3.0",
27
+ "@semantic-release/changelog": "^6.0.3",
28
+ "@semantic-release/commit-analyzer": "^13.0.1",
29
+ "@semantic-release/git": "^10.0.1",
30
+ "@semantic-release/github": "^12.0.2",
31
+ "@semantic-release/npm": "^13.1.2",
32
+ "@semantic-release/release-notes-generator": "^14.1.0",
33
+ "@cucumber/cucumber": "^12.9.0",
34
+ "@testcontainers/rabbitmq": "^12.0.0",
35
+ "@toa.io/generic": "^1.0.0-alpha.208",
36
+ "@types/amqplib": "^0.10.8",
37
+ "@types/jest": "^30.0.0",
38
+ "husky": "^9.1.7",
39
+ "jest": "^30.4.2",
40
+ "js-yaml": "^4.1.1",
41
+ "randomstring": "^1.3.1",
39
42
  "snazzy": "^9.0.0",
40
- "standard": "^17.1.0"
43
+ "standard": "^17.1.2",
44
+ "testcontainers": "^12.0.0"
41
45
  },
42
46
  "dependencies": {
43
- "amqplib": "^0.10.4",
44
- "msgpackr": "^1.10.2",
47
+ "amqplib": "^2.0.1",
48
+ "msgpackr": "^2.0.1",
45
49
  "promex": "^0.1.1",
46
50
  "reretry": "^0.1.0"
47
51
  },
48
52
  "scripts": {
49
- "prepare": "echo run: husky install",
53
+ "prepare": "husky",
50
54
  "test": "standard && jest",
51
55
  "features": "cucumber-js --fail-fast -t 'not @manual'",
52
56
  "lint": "standard --fix --verbose | snazzy"
@@ -55,8 +59,5 @@
55
59
  "env": [
56
60
  "jest"
57
61
  ]
58
- },
59
- "workspaces": [
60
- "."
61
- ]
62
+ }
62
63
  }
package/types/io.d.ts CHANGED
@@ -15,7 +15,7 @@ declare namespace comq {
15
15
  }
16
16
 
17
17
  interface Destroyable extends EventEmitter {
18
- destroy (): void
18
+ destroy(): void
19
19
  }
20
20
 
21
21
  interface Request {
@@ -25,51 +25,55 @@ declare namespace comq {
25
25
  }
26
26
 
27
27
  interface IO extends _diagnostics.Diagnosable {
28
- reply (queue: string, produce: Producer): Promise<void>
28
+ reply(queue: string, produce: Producer): Promise<void>
29
29
 
30
- request<Reply = any, Request = any> (queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
30
+ request<Reply = any, Request = any>(queue: string, payload: Request, encoding?: _encoding.Encoding): Promise<Reply> | Promise<Readable>
31
31
 
32
- request (queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
32
+ request(queue: string, stream: Readable, encoding?: _encoding.Encoding): Promise<Readable>
33
33
 
34
- consume<T = any> (exchange: string, group: string, consumer: Consumer<T>): Promise<void>
34
+ consume<T = any>(exchange: string, group: string, consumer: Consumer<T>): Promise<void>
35
35
 
36
- consume<T = any> (exchange: string, consumer: Consumer<T>): Promise<void>
36
+ consume<T = any>(exchange: string, consumer: Consumer<T>): Promise<void>
37
37
 
38
- emit (exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
38
+ emit(exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
39
39
 
40
- emit (exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
40
+ emit(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
41
41
 
42
- emit (exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
42
+ emit(exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
43
43
 
44
- emit (exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
44
+ emit(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
45
45
 
46
- enqueue (exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
46
+ enqueue(exchange: string, payload: any, encoding?: _encoding.Encoding): Promise<void>
47
47
 
48
- enqueue (exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
48
+ enqueue(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
49
49
 
50
- enqueue (exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
50
+ enqueue(exchange: string, stream: Readable, encoding?: _encoding.Encoding): Promise<void>
51
51
 
52
- enqueue (exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
52
+ enqueue(exchange: string, stream: Readable, properties?: _amqp.Properties): Promise<void>
53
53
 
54
- process<T = any> (queue: string, processor: Consumer<T>): Promise<void>
54
+ process<T = any>(queue: string, processor: Consumer<T>): Promise<void>
55
55
 
56
- seal (): Promise<void>
56
+ seal(): Promise<void>
57
57
 
58
- close (): Promise<void>
58
+ close(): Promise<void>
59
59
 
60
- diagnose (event: 'open', listener: (index?: number) => void): void
60
+ diagnose(event: 'open', listener: (index?: number) => void): void
61
61
 
62
- diagnose (event: 'close', listener: (index?: number) => void): void
62
+ diagnose(event: 'close', listener: (index?: number) => void): void
63
63
 
64
- diagnose (event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
64
+ diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
65
65
 
66
- diagnose (event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
66
+ diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void
67
67
 
68
- diagnose (event: 'remove', listener: (index?: number) => void): void
68
+ diagnose(event: 'remove', listener: (index?: number) => void): void
69
69
 
70
- diagnose (event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
70
+ diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
71
71
 
72
- diagnose (event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
72
+ diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void
73
+
74
+ diagnose(event: 'pause', listener: (channel: _topology.type) => void): void
75
+
76
+ diagnose(event: 'resume', listener: (channel: _topology.type) => void): void
73
77
  }
74
78
  }
75
79
 
package/lerna.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "version": "0.12.0",
3
- "conventionalCommits": true
4
- }