@yarkivaev/source-to-sink 1.3.7 → 1.3.8

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/eslint.config.js CHANGED
@@ -150,6 +150,7 @@ export default [
150
150
  files: ['src/modbusSource.js'],
151
151
  rules: {
152
152
  'max-params': ['error', 8],
153
+ 'max-lines': ['error', { 'max': 250, 'skipBlankLines': true, 'skipComments': true }],
153
154
  'max-lines-per-function': ['error', { 'max': 60, 'skipBlankLines': true, 'skipComments': true }],
154
155
  'max-statements': ['error', 20]
155
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/source-to-sink",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -124,6 +124,10 @@ function attempt(bag) {
124
124
  }
125
125
  bag.delay = bag.interval;
126
126
  bag.state = connected(bag.client);
127
+ bag.client.setTimeout(bag.interval * 1000);
128
+ bag.client.once('close', () => {
129
+ bag.lost();
130
+ });
127
131
  bag.source.start();
128
132
  }).catch((err) => {
129
133
  if (token !== bag.epoch) {
@@ -139,6 +143,28 @@ function attempt(bag) {
139
143
  });
140
144
  }
141
145
 
146
+ /**
147
+ * Resets a live session and retries through attempt() backoff.
148
+ *
149
+ * @param {object} bag - Mutable lifecycle bag
150
+ */
151
+ function resume(bag) {
152
+ if (!bag.state.connected()) {
153
+ return;
154
+ }
155
+ bag.source.stop();
156
+ const { client } = bag;
157
+ bag.client = undefined;
158
+ bag.state = idle();
159
+ drop(client);
160
+ bag.log.error(`Connection to ${bag.target} lost, retrying in ${bag.delay}s`);
161
+ const handle = setTimeout(() => {
162
+ attempt(bag);
163
+ }, bag.delay * 1000);
164
+ bag.state = retrying(handle);
165
+ bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
166
+ }
167
+
142
168
  /**
143
169
  * Shared Modbus polling lifecycle for TCP and RTU transports.
144
170
  *
@@ -155,9 +181,17 @@ function modbusPollingSource(options) {
155
181
  maxDelay: 300,
156
182
  delay: interval
157
183
  };
184
+ bag.lost = function lost() {
185
+ resume(bag);
186
+ };
158
187
  async function fetch() {
159
- const result = await bag.client.readHoldingRegisters(address, count);
160
- return [result.data];
188
+ try {
189
+ const result = await bag.client.readHoldingRegisters(address, count);
190
+ return [result.data];
191
+ } catch (err) {
192
+ bag.lost();
193
+ throw err;
194
+ }
161
195
  }
162
196
  bag.source = pollingSource(fetch, interval, collector, clk);
163
197
  return {
@@ -179,9 +213,10 @@ function modbusPollingSource(options) {
179
213
  if (bag.state.retrying()) {
180
214
  bag.state.cancel();
181
215
  }
182
- drop(bag.client);
216
+ const { client } = bag;
183
217
  bag.client = undefined;
184
218
  bag.state = idle();
219
+ drop(client);
185
220
  }
186
221
  };
187
222
  }
@@ -105,6 +105,114 @@ function until(probe, limit) {
105
105
  return new Promise(tick);
106
106
  }
107
107
 
108
+ /**
109
+ * TCP listener that counts accepted sockets and can drop them.
110
+ *
111
+ * @returns {Promise<object>} Gate with port, accepted, drop, close
112
+ */
113
+ function sessionListen() {
114
+ let accepted = 0;
115
+ const sockets = new Set();
116
+ const server = net.createServer((sock) => {
117
+ accepted += 1;
118
+ sockets.add(sock);
119
+ sock.on('close', () => {
120
+ sockets.delete(sock);
121
+ });
122
+ });
123
+ return new Promise((resolve) => {
124
+ server.listen(0, '127.0.0.1', () => {
125
+ resolve({
126
+ port: server.address().port,
127
+ accepted() {
128
+ return accepted;
129
+ },
130
+ drop() {
131
+ for (const sock of sockets) {
132
+ sock.destroy();
133
+ }
134
+ },
135
+ close() {
136
+ for (const sock of sockets) {
137
+ sock.destroy();
138
+ }
139
+ return new Promise((done) => {
140
+ server.close(done);
141
+ });
142
+ }
143
+ });
144
+ });
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Builds a Modbus TCP Read Holding Registers response.
150
+ *
151
+ * @param {Buffer} req - Request frame
152
+ * @param {number[]} values - Register values
153
+ * @returns {Buffer} Response frame
154
+ */
155
+ function holding(req, values) {
156
+ const tid = req.readUInt16BE(0);
157
+ const uid = req.readUInt8(6);
158
+ const bytes = values.length * 2;
159
+ const out = Buffer.alloc(9 + bytes);
160
+ out.writeUInt16BE(tid, 0);
161
+ out.writeUInt16BE(0, 2);
162
+ out.writeUInt16BE(3 + bytes, 4);
163
+ out.writeUInt8(uid, 6);
164
+ out.writeUInt8(3, 7);
165
+ out.writeUInt8(bytes, 8);
166
+ for (let i = 0; i < values.length; i += 1) {
167
+ out.writeUInt16BE(values[i], 9 + i * 2);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * TCP listener that stays silent on the first socket then answers later ones.
174
+ *
175
+ * @param {number[]} values - Holding register values
176
+ * @returns {Promise<object>} Gate with port, accepted, close
177
+ */
178
+ function recoverListen(values) {
179
+ let accepted = 0;
180
+ const sockets = new Set();
181
+ const server = net.createServer((sock) => {
182
+ accepted += 1;
183
+ sockets.add(sock);
184
+ sock.on('close', () => {
185
+ sockets.delete(sock);
186
+ });
187
+ if (accepted === 1) {
188
+ return;
189
+ }
190
+ sock.on('data', (req) => {
191
+ if (req.length >= 12 && req.readUInt8(7) === 3) {
192
+ sock.write(holding(req, values));
193
+ }
194
+ });
195
+ });
196
+ return new Promise((resolve) => {
197
+ server.listen(0, '127.0.0.1', () => {
198
+ resolve({
199
+ port: server.address().port,
200
+ accepted() {
201
+ return accepted;
202
+ },
203
+ close() {
204
+ for (const sock of sockets) {
205
+ sock.destroy();
206
+ }
207
+ return new Promise((done) => {
208
+ server.close(done);
209
+ });
210
+ }
211
+ });
212
+ });
213
+ });
214
+ }
215
+
108
216
  describe('modbusSource', () => {
109
217
  it('throws on empty host', () => {
110
218
  const collector = { accept: () => {} };
@@ -311,4 +419,64 @@ describe('modbusSource', () => {
311
419
  await gate.close();
312
420
  assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
313
421
  });
422
+
423
+ it('opens a new TCP socket after the device stays silent or drops the session', async function() {
424
+ this.timeout(6000);
425
+ const gate = await sessionListen();
426
+ const source = modbusSource(
427
+ '127.0.0.1',
428
+ gate.port,
429
+ 4000,
430
+ 3 + Math.floor(Math.random() * 8),
431
+ 0.05,
432
+ { accept() {} },
433
+ fakeClock(11 + Math.floor(Math.random() * 100)),
434
+ { error() {} }
435
+ );
436
+ source.start();
437
+ await until(() => {
438
+ return gate.accepted() >= 1;
439
+ }, 2000);
440
+ await settle(80);
441
+ gate.drop();
442
+ await until(() => {
443
+ return gate.accepted() >= 2;
444
+ }, 3000);
445
+ const accepted = gate.accepted();
446
+ source.stop();
447
+ await gate.close();
448
+ assert.ok(accepted >= 2, 'silent or dropped session did not open a new Modbus TCP socket');
449
+ });
450
+
451
+ it('forwards a new sample after the device answers again', async function() {
452
+ this.timeout(6000);
453
+ const count = 3 + Math.floor(Math.random() * 6);
454
+ const values = Array.from({ length: count }, () => {
455
+ return 1000 + Math.floor(Math.random() * 500);
456
+ });
457
+ const samples = [];
458
+ const gate = await recoverListen(values);
459
+ const source = modbusSource(
460
+ '127.0.0.1',
461
+ gate.port,
462
+ 4000,
463
+ count,
464
+ 0.05,
465
+ {
466
+ accept(entry) {
467
+ samples.push(entry);
468
+ }
469
+ },
470
+ fakeClock(13 + Math.floor(Math.random() * 100)),
471
+ { error() {} }
472
+ );
473
+ source.start();
474
+ await until(() => {
475
+ return samples.length >= 1;
476
+ }, 4000);
477
+ const sample = samples[0];
478
+ source.stop();
479
+ await gate.close();
480
+ assert.deepStrictEqual(sample, values, 'restored device did not deliver a new sample');
481
+ });
314
482
  });