@yarkivaev/source-to-sink 1.3.6 → 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.6",
3
+ "version": "1.3.8",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -85,6 +85,28 @@ function connected(client) {
85
85
  };
86
86
  }
87
87
 
88
+ /**
89
+ * Destroys a Modbus client without throwing if already closed.
90
+ *
91
+ * @param {object} client - ModbusRTU client
92
+ */
93
+ function drop(client) {
94
+ if (client && typeof client.destroy === 'function') {
95
+ client.destroy(() => {
96
+ return undefined;
97
+ });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Builds a ModbusRTU client for one connect attempt.
103
+ *
104
+ * @returns {object} Fresh ModbusRTU client
105
+ */
106
+ function create() {
107
+ return new ModbusRTU();
108
+ }
109
+
88
110
  /**
89
111
  * Starts one connect attempt and ignores results after stop().
90
112
  *
@@ -93,18 +115,25 @@ function connected(client) {
93
115
  function attempt(bag) {
94
116
  const token = bag.epoch;
95
117
  bag.state = opening();
118
+ drop(bag.client);
119
+ bag.client = bag.create();
96
120
  bag.connect(bag.client).then(() => {
97
121
  if (token !== bag.epoch) {
98
- bag.client.close();
122
+ drop(bag.client);
99
123
  return;
100
124
  }
101
125
  bag.delay = bag.interval;
102
126
  bag.state = connected(bag.client);
127
+ bag.client.setTimeout(bag.interval * 1000);
128
+ bag.client.once('close', () => {
129
+ bag.lost();
130
+ });
103
131
  bag.source.start();
104
132
  }).catch((err) => {
105
133
  if (token !== bag.epoch) {
106
134
  return;
107
135
  }
136
+ drop(bag.client);
108
137
  bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
109
138
  const handle = setTimeout(() => {
110
139
  attempt(bag);
@@ -114,6 +143,28 @@ function attempt(bag) {
114
143
  });
115
144
  }
116
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
+
117
168
  /**
118
169
  * Shared Modbus polling lifecycle for TCP and RTU transports.
119
170
  *
@@ -121,19 +172,28 @@ function attempt(bag) {
121
172
  * @returns {object} Source with start() and stop() methods
122
173
  */
123
174
  function modbusPollingSource(options) {
124
- const { address, count, interval, collector, clk, client } = options;
125
- async function fetch() {
126
- const result = await client.readHoldingRegisters(address, count);
127
- return [result.data];
128
- }
175
+ const { address, count, interval, collector, clk } = options;
129
176
  const bag = {
130
177
  ...options,
178
+ client: undefined,
131
179
  state: idle(),
132
180
  epoch: 0,
133
181
  maxDelay: 300,
134
- source: pollingSource(fetch, interval, collector, clk),
135
182
  delay: interval
136
183
  };
184
+ bag.lost = function lost() {
185
+ resume(bag);
186
+ };
187
+ async function fetch() {
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
+ }
195
+ }
196
+ bag.source = pollingSource(fetch, interval, collector, clk);
137
197
  return {
138
198
  /**
139
199
  * Connects to the Modbus device and starts polling.
@@ -153,10 +213,10 @@ function modbusPollingSource(options) {
153
213
  if (bag.state.retrying()) {
154
214
  bag.state.cancel();
155
215
  }
156
- if (bag.state.connected() || bag.state.opening()) {
157
- bag.client.close();
158
- }
216
+ const { client } = bag;
217
+ bag.client = undefined;
159
218
  bag.state = idle();
219
+ drop(client);
160
220
  }
161
221
  };
162
222
  }
@@ -195,11 +255,11 @@ export default function modbusSource(host, port, address, count, interval, colle
195
255
  if (typeof count !== 'number' || count <= 0) {
196
256
  throw new Error(`Count must be a positive number, got: ${count}`);
197
257
  }
198
- const client = new ModbusRTU();
199
258
  function connect(modbusClient) {
200
259
  return modbusClient.connectTCP(host, { port });
201
260
  }
202
261
  return modbusPollingSource({
262
+ create,
203
263
  connect,
204
264
  target: `${host}:${port}`,
205
265
  address,
@@ -207,7 +267,6 @@ export default function modbusSource(host, port, address, count, interval, colle
207
267
  interval,
208
268
  collector,
209
269
  clk,
210
- client,
211
270
  log
212
271
  });
213
272
  }
@@ -248,7 +307,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
248
307
  if (typeof count !== 'number' || count <= 0) {
249
308
  throw new Error(`Count must be a positive number, got: ${count}`);
250
309
  }
251
- const client = new ModbusRTU();
252
310
  const slaveId = serial.slaveId || 1;
253
311
  const dataBits = serial.dataBits || 8;
254
312
  const stopBits = serial.stopBits || 1;
@@ -264,6 +322,7 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
264
322
  });
265
323
  }
266
324
  return modbusPollingSource({
325
+ create,
267
326
  connect,
268
327
  target: path,
269
328
  address,
@@ -271,7 +330,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
271
330
  interval,
272
331
  collector,
273
332
  clk,
274
- client,
275
333
  log
276
334
  });
277
335
  }
@@ -64,14 +64,17 @@ export default function pollingSource(fetch, interval, collector, clk) {
64
64
  }
65
65
  let state = idle();
66
66
  let since = clk.millis();
67
- async function poll() {
67
+ function poll() {
68
68
  const from = since;
69
69
  const until = clk.millis();
70
70
  since = until;
71
- const result = await fetch(from, until);
72
- for (const entry of result) {
73
- collector.accept(entry);
74
- }
71
+ Promise.resolve(fetch(from, until)).then((result) => {
72
+ for (const entry of result) {
73
+ collector.accept(entry);
74
+ }
75
+ }).catch(() => {
76
+ return undefined;
77
+ });
75
78
  }
76
79
  return {
77
80
  /**
@@ -59,6 +59,27 @@ function settle(ms) {
59
59
  });
60
60
  }
61
61
 
62
+ /**
63
+ * Collects unhandledRejection events until stop().
64
+ *
65
+ * @returns {object} Collector with seen() and stop()
66
+ */
67
+ function watchRejects() {
68
+ const seen = [];
69
+ function onReject(err) {
70
+ seen.push(err);
71
+ }
72
+ process.on('unhandledRejection', onReject);
73
+ return {
74
+ seen() {
75
+ return seen.length;
76
+ },
77
+ stop() {
78
+ process.off('unhandledRejection', onReject);
79
+ }
80
+ };
81
+ }
82
+
62
83
  /**
63
84
  * Waits until probe is true or the timeout elapses.
64
85
  *
@@ -84,6 +105,114 @@ function until(probe, limit) {
84
105
  return new Promise(tick);
85
106
  }
86
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
+
87
216
  describe('modbusSource', () => {
88
217
  it('throws on empty host', () => {
89
218
  const collector = { accept: () => {} };
@@ -200,6 +329,28 @@ describe('modbusSource', () => {
200
329
  assert.strictEqual(true, true, 'Should not crash on unreachable host');
201
330
  });
202
331
 
332
+ it('does not emit unhandled rejection after connection retry', async function() {
333
+ this.timeout(4000);
334
+ const watch = watchRejects();
335
+ const log = { error() {} };
336
+ const port = 65000 + Math.floor(Math.random() * 500);
337
+ const source = modbusSource(
338
+ '127.0.0.1',
339
+ port,
340
+ 4000,
341
+ 14,
342
+ 0.05,
343
+ { accept() {} },
344
+ fakeClock(1 + Math.floor(Math.random() * 100)),
345
+ log
346
+ );
347
+ source.start();
348
+ await settle(400);
349
+ source.stop();
350
+ watch.stop();
351
+ assert.strictEqual(watch.seen(), 0, 'retry leaked an unhandled rejection');
352
+ });
353
+
203
354
  it('does not open a second TCP socket when start is called twice', async function() {
204
355
  const gate = await countingListen();
205
356
  const source = modbusSource(
@@ -268,4 +419,64 @@ describe('modbusSource', () => {
268
419
  await gate.close();
269
420
  assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
270
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
+ });
271
482
  });
@@ -91,6 +91,26 @@ describe('pollingSource', () => {
91
91
  assert.strictEqual(count, before, 'Should stop polling after stop');
92
92
  });
93
93
 
94
+ it('does not emit unhandled rejection when fetch rejects', async () => {
95
+ const seen = [];
96
+ function onReject(err) {
97
+ seen.push(err);
98
+ }
99
+ process.on('unhandledRejection', onReject);
100
+ const label = `\u00e9${Math.random()}`;
101
+ const fetch = () => {
102
+ return Promise.reject(new Error(label));
103
+ };
104
+ const source = pollingSource(fetch, 0.03, { accept() {} }, fakeClock(1 + Math.floor(Math.random() * 100)));
105
+ source.start();
106
+ await new Promise((resolve) => {
107
+ setTimeout(resolve, 80);
108
+ });
109
+ source.stop();
110
+ process.off('unhandledRejection', onReject);
111
+ assert.strictEqual(seen.length, 0, 'poll rejection leaked as unhandled');
112
+ });
113
+
94
114
  it('throws on missing fetch', () => {
95
115
  const collector = { accept: () => {} };
96
116
  const clk = fakeClock(0);