@flux-control/effect-modbus-rs 0.2.0 → 0.3.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.
- package/README.md +198 -2
- package/dist/index.d.ts +54 -0
- package/dist/index.js +394 -173
- package/dist/src/AsciiTransportService.d.ts +26 -5
- package/dist/src/RtuTransportService.d.ts +26 -5
- package/dist/src/SerialTransportService.d.ts +6 -5
- package/dist/src/TcpTransportService.d.ts +26 -5
- package/dist/src/WasmAsciiTransportService.d.ts +20 -3
- package/dist/src/WasmRtuTransportService.d.ts +20 -3
- package/dist/src/WasmWsTransportService.d.ts +18 -3
- package/dist/src/connection.d.ts +170 -0
- package/dist/src/errors.d.ts +25 -1
- package/dist/src/mocks.d.ts +46 -7
- package/dist/src/modbus-client.d.ts +54 -3
- package/dist/src/resilience.test.d.ts +1 -0
- package/dist/src/retry.d.ts +227 -0
- package/dist/src/retry.test.d.ts +1 -0
- package/dist/src/shared-transport.d.ts +107 -6
- package/dist/src/shared-transport.test.d.ts +1 -0
- package/dist/src/upstream-options.test.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -161,7 +161,7 @@ Each transport is a scoped `Effect.Service`. You provide it with `Effect.provide
|
|
|
161
161
|
| `WasmRtuTransportService` (browser) | `{ port, baudRate, ... }` | `WasmRtuTransport.open()` |
|
|
162
162
|
| `WasmAsciiTransportService` (browser) | `{ port, baudRate, ... }` | `WasmAsciiTransport.open()` |
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
Browser transport option types are re-exported from `modbus-rs/web` unchanged. The native ones are narrowed — `RtuTransportOpenOptions`, `AsciiTransportOpenOptions`, and `TcpTransportOpenOptions` are their `modbus-rs` counterparts minus the retry knobs, for the reasons in [Why the upstream retry knobs are withheld](#why-the-upstream-retry-knobs-are-withheld).
|
|
165
165
|
|
|
166
166
|
### Abstract serial transport
|
|
167
167
|
|
|
@@ -245,6 +245,199 @@ Errors from the underlying Rust layer are mapped to typed `Effect` errors via `D
|
|
|
245
245
|
|
|
246
246
|
Handle with `Effect.catchTags`. The `ModbusError` union type covers all seven variants.
|
|
247
247
|
|
|
248
|
+
## Resilience: retries, reconnection, and circuit breaking
|
|
249
|
+
|
|
250
|
+
> **These are application-level policies, and they are opt-in.**
|
|
251
|
+
>
|
|
252
|
+
> They are also the _only_ retries in play. The **transport-level** knobs the underlying `modbus-rs` library offers — `retryAttempts`, `retryDelayMs`, and `retryBackoffStrategy` — are **not accepted** by any transport constructor here. Passing one is a type error. See [Why the upstream retry knobs are withheld](#why-the-upstream-retry-knobs-are-withheld).
|
|
253
|
+
|
|
254
|
+
Resilience belongs to the **transport**, not to call sites. Attach a policy where the transport is created and every client derived from it carries it:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const layer = TcpTransportService.Default({
|
|
258
|
+
host: '192.168.1.50',
|
|
259
|
+
port: 502,
|
|
260
|
+
retry: RetryPolicies.tcp(), // applied to every operation
|
|
261
|
+
reconnect: {}, // supervised reconnect + circuit breaker
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// call sites never mention retries
|
|
265
|
+
const client = yield * transport.withClient(1);
|
|
266
|
+
yield * client.readHoldingRegisters({ address: 0, quantity: 10 });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
With neither option set, a transport behaves exactly as it always has: one attempt per operation, reconnection only when you ask for it.
|
|
270
|
+
|
|
271
|
+
### Templates
|
|
272
|
+
|
|
273
|
+
| Template | Shape | For |
|
|
274
|
+
| ---------------------------- | ------------------------------------------------------- | ------------------------------------- |
|
|
275
|
+
| `RetryPolicies.none()` | 1 attempt | Opting out of a wider policy |
|
|
276
|
+
| `RetryPolicies.serial()` | 3 retries, 50 ms base, ×2, 1 s ceiling | RS-232/485 — collisions, noise bursts |
|
|
277
|
+
| `RetryPolicies.tcp()` | 4 retries, 100 ms base, ×2, 5 s ceiling | Modbus/TCP — sockets and gateways |
|
|
278
|
+
| `RetryPolicies.persistent()` | 10 retries, 250 ms base, ×2, 30 s ceiling, 5 min budget | Long-running background polling |
|
|
279
|
+
|
|
280
|
+
All four jitter their delays — see [Backoff and jitter](#backoff-and-jitter) — and none retry `ModbusInvalidArgumentError` or a deterministic exception code.
|
|
281
|
+
|
|
282
|
+
Every template is a factory taking overrides, so it doubles as a starting point. Overrides merge into the template rather than replacing it wholesale:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
RetryPolicies.serial({
|
|
286
|
+
maxRetries: 6,
|
|
287
|
+
errors: { ModbusTimeoutError: { baseDelay: '80 millis' } },
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`makeRetryPolicy(options)` builds one from scratch with the same options.
|
|
292
|
+
|
|
293
|
+
### Overriding per client and per operation
|
|
294
|
+
|
|
295
|
+
One bus often hosts device types that need different logic. A per-client policy **replaces** the transport's, so overrides can never multiply attempt counts:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const meter = yield * transport.withClient(1, { retry: RetryPolicies.serial() });
|
|
299
|
+
const plc = yield * transport.withClient(2, { retry: RetryPolicies.serial({ maxRetries: 8 }) });
|
|
300
|
+
const legacy = yield * transport.withClient(3, { retry: RetryPolicies.none() });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Clients built for the same unit ID under different policies share one underlying connection.
|
|
304
|
+
|
|
305
|
+
`client.withRetry(policy)` does the same for a single operation:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
yield * client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Resolution order is **per-operation → per-client → transport → none**. First match wins; the others are discarded, not combined.
|
|
312
|
+
|
|
313
|
+
#### Replacing vs. wrapping
|
|
314
|
+
|
|
315
|
+
Two things look alike at a call site — both read as "attach a policy here" — but behave differently, and the difference is worth internalising:
|
|
316
|
+
|
|
317
|
+
| Form | Effect on the policy already in force |
|
|
318
|
+
| ------------------------------- | ------------------------------------- |
|
|
319
|
+
| `withClient(unitId, { retry })` | **Replaces** it |
|
|
320
|
+
| `client.withRetry(policy)` | **Replaces** it |
|
|
321
|
+
| `.pipe(retryModbus(policy))` | **Wraps** it — the two nest |
|
|
322
|
+
|
|
323
|
+
The deciding factor is whether the policy goes _through_ the client or _around_ it. The first two are resolved inside the client when it is built, so the previous policy is never applied. `retryModbus` is a free function piped around an effect the client has **already** wrapped in its own retry — nothing in that path can see the inner policy, so both run and the attempt counts multiply.
|
|
324
|
+
|
|
325
|
+
Concretely, against a transport policy of `maxRetries: 2` (3 attempts):
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
transport.withClient(1, { retry: fast(3) }) // 4 attempts (replaced)
|
|
329
|
+
client.withRetry(fast(4)).readHoldingRegisters(...) // 5 attempts (replaced)
|
|
330
|
+
client.readHoldingRegisters(...).pipe(retryModbus(fast(3))) // 12 attempts (3 × 4)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
That last form is only correct over a `RetryPolicies.none()` client — see [Retrying a transaction](#retrying-a-transaction).
|
|
334
|
+
|
|
335
|
+
### Error-aware by construction
|
|
336
|
+
|
|
337
|
+
Retrying is only correct for failures that can plausibly resolve themselves, so the policy decides per error:
|
|
338
|
+
|
|
339
|
+
| Error | Retried by default |
|
|
340
|
+
| ----------------------------- | ------------------------------------------------------------------ |
|
|
341
|
+
| `ModbusTimeoutError` | yes — slow turnaround, bus contention |
|
|
342
|
+
| `ModbusTransportError` | yes — framing/CRC corruption |
|
|
343
|
+
| `ModbusConnectionClosedError` | yes — and hands the link to the supervisor |
|
|
344
|
+
| `ModbusCircuitOpenError` | yes — refused without touching the bus, so it is cheap to wait out |
|
|
345
|
+
| `ModbusExceptionError` | only for codes `5`, `6`, `10`, `11` (busy / gateway) |
|
|
346
|
+
| `ModbusInvalidArgumentError` | no — the answer will not change |
|
|
347
|
+
| `ModbusNotConnectedError` | no |
|
|
348
|
+
| `ModbusInternalError` | no |
|
|
349
|
+
|
|
350
|
+
Any of these can be switched off (`errors: { ModbusTimeoutError: false }`), switched on, or given their own backoff curve (`errors: { ModbusConnectionClosedError: { baseDelay: '250 millis' } }`). The retry budget is shared across categories — only the delay curve is per-error — so a mixed failure sequence still stops after `maxRetries`.
|
|
351
|
+
|
|
352
|
+
### Backoff and jitter
|
|
353
|
+
|
|
354
|
+
Delays follow `min(maxDelay, baseDelay × factor ** retryIndex)`, then get jittered.
|
|
355
|
+
|
|
356
|
+
**Jitter is on by default** — for `makeRetryPolicy()` and for every template, none of which opts out. Each delay is multiplied by a random factor so a fleet of pollers does not re-hit a recovering device in lockstep:
|
|
357
|
+
|
|
358
|
+
| `jitter` | Delay |
|
|
359
|
+
| ---------------------------- | -------------------------------------------------------- |
|
|
360
|
+
| omitted, or `true` (default) | ±20% — Effect's `0.8 – 1.2` multiplier range |
|
|
361
|
+
| `false` | exact, unrandomised delays — useful for assertable tests |
|
|
362
|
+
| `{ min: 0.5, max: 1.5 }` | custom multiplier range |
|
|
363
|
+
|
|
364
|
+
So `RetryPolicies.tcp()` waits roughly 80–120 ms before its first retry, not exactly 100 ms.
|
|
365
|
+
|
|
366
|
+
### Supervised reconnection and the circuit breaker
|
|
367
|
+
|
|
368
|
+
Passing `reconnect` hands reconnection to a supervisor fiber owned by the transport — **one reconnect for the whole application**, however many fibers were in flight when the link dropped:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
TcpTransportService.Default({
|
|
372
|
+
host,
|
|
373
|
+
port,
|
|
374
|
+
reconnect: {
|
|
375
|
+
policy: RetryPolicies.tcp(), // how reconnect attempts are spaced
|
|
376
|
+
resetAfter: '30 seconds', // how long the circuit stays open before probing
|
|
377
|
+
triggerOn: ['ModbusConnectionClosedError', 'ModbusTransportError'],
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
While the link is being re-established, operations are refused with `ModbusCircuitOpenError` instead of queueing requests onto a dead bus. Because that error is retryable by default and costs nothing on the wire, a polling loop with a generous policy simply rides out the outage; one with a short budget fails fast and lets the caller decide.
|
|
383
|
+
|
|
384
|
+
State transitions are published on `transport.connectionState`:
|
|
385
|
+
|
|
386
|
+
| State | Meaning |
|
|
387
|
+
| -------------- | -------------------------------------------------------------------------------------- |
|
|
388
|
+
| `Disconnected` | Never opened, or closed. The next operation opens it lazily. |
|
|
389
|
+
| `Connected` | Usable. |
|
|
390
|
+
| `Reconnecting` | Supervisor is re-establishing the link. Operations refused. |
|
|
391
|
+
| `Down` | Attempts exhausted; waiting out `resetAfter` before probing again. Operations refused. |
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
yield *
|
|
395
|
+
Stream.runForEach(transport.connectionState.changes, (state) =>
|
|
396
|
+
Console.log(`link: ${state._tag}`),
|
|
397
|
+
);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Retrying a transaction
|
|
401
|
+
|
|
402
|
+
`retryModbus(policy)` remains exported for the one case the transport cannot express: driving a **compound** operation as a unit, where retrying individual frames would be wrong.
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
const client = yield * transport.withClient(1, { retry: RetryPolicies.none() });
|
|
406
|
+
|
|
407
|
+
yield *
|
|
408
|
+
Effect.gen(function* () {
|
|
409
|
+
const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
410
|
+
yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
|
|
411
|
+
}).pipe(retryModbus(RetryPolicies.tcp()));
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Take a `RetryPolicies.none()` client first. Unlike `withClient({ retry })` and `client.withRetry()`, which replace the policy in force, `retryModbus` wraps whatever the client is already doing — so over a policied client the two nest and the attempt counts multiply. See [Replacing vs. wrapping](#replacing-vs-wrapping).
|
|
415
|
+
|
|
416
|
+
See `examples/retry-policies.ts` for a runnable walkthrough.
|
|
417
|
+
|
|
418
|
+
### Why the upstream retry knobs are withheld
|
|
419
|
+
|
|
420
|
+
`modbus-rs` exposes `retryAttempts`, `retryDelayMs`, and `retryBackoffStrategy` on its transport options. This package removes all three from every transport constructor, so setting one is a compile error rather than a documented hazard:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
TcpTransportService.Default({ host, port, retryAttempts: 3 });
|
|
424
|
+
// ^^^^^^^^^^^^^ Object literal may only specify
|
|
425
|
+
// known properties, and 'retryAttempts' does not
|
|
426
|
+
// exist in type 'TcpTransportOpenOptions & …'
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
They are withheld rather than merely discouraged because enabling them is never the right call under this design:
|
|
430
|
+
|
|
431
|
+
- **They retry below the Effect boundary.** A failure they paper over never reaches your policy, the circuit breaker, or your logs. The caller sees one slow success instead of several failures and a recovery, and any caller-side `Effect.timeout` is measuring inflated time.
|
|
432
|
+
- **They reconnect.** Upstream re-establishes the link inline and replays in-flight requests after it, which races the single supervisor fiber that is supposed to own reconnection for the whole transport.
|
|
433
|
+
- **They multiply.** Neither layer knows about the other, so attempt counts compound and the two backoff curves interleave.
|
|
434
|
+
- **`retryDelayMs` is flat and unjittered** — exactly the lockstep-collision pattern `RetryPolicies.serial()` exists to break up on a shared RS-485 segment.
|
|
435
|
+
- **`retryBackoffStrategy` does nothing.** It is documented upstream as inert and reserved for future implementation, so `'exponential'` silently gets you a flat delay.
|
|
436
|
+
|
|
437
|
+
Use `retry` and `reconnect` on the transport instead. If you genuinely need frame-level resends, construct a raw `modbus-rs` client directly, where that trade-off is explicit rather than hidden under an Effect service.
|
|
438
|
+
|
|
439
|
+
The narrowed option types are exported as `RtuTransportOpenOptions`, `AsciiTransportOpenOptions`, and `TcpTransportOpenOptions`, alongside the generic `WithoutUpstreamRetry<T>` and the `UpstreamRetryOptionKey` union.
|
|
440
|
+
|
|
248
441
|
## Testing with mocks
|
|
249
442
|
|
|
250
443
|
Each transport service provides a `makeMockTransport(devices)` static method that returns an in-memory mock `Layer` — no serial port or network required.
|
|
@@ -316,7 +509,9 @@ src/
|
|
|
316
509
|
errors.ts — Data.TaggedError types + toModbusError converter
|
|
317
510
|
modbus-client.ts — EffectModbusClient interface + factory (native + WASM)
|
|
318
511
|
mocks.ts — Schema-validated mock transport + slave device definitions
|
|
319
|
-
|
|
512
|
+
connection.ts — Connection state machine, reconnect supervisor, circuit breaker
|
|
513
|
+
retry.ts — Opt-in retry policies (backoff, jitter, per-error rules)
|
|
514
|
+
shared-transport.ts — Generic scoped transport lifecycle management, WithoutUpstreamRetry
|
|
320
515
|
RtuTransportService.ts — Scoped Effect.Service wrapping AsyncRtuTransport
|
|
321
516
|
TcpTransportService.ts — Scoped Effect.Service wrapping AsyncTcpTransport
|
|
322
517
|
AsciiTransportService.ts — Scoped Effect.Service wrapping AsyncAsciiTransport
|
|
@@ -339,6 +534,7 @@ examples/
|
|
|
339
534
|
rtu-mock.ts — RTU with in-memory mock
|
|
340
535
|
tcp-mock.ts — TCP with in-memory mock (multi-device)
|
|
341
536
|
ascii-mock.ts — ASCII with in-memory mock (error-case)
|
|
537
|
+
retry-policies.ts — Transport-owned resilience: policies, overrides, transactions
|
|
342
538
|
tcp-polling-stream.ts — TCP polling, reconnect, and stream
|
|
343
539
|
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
|
|
344
540
|
tcp-server.ts — TCP server example
|
package/dist/index.d.ts
CHANGED
|
@@ -46,14 +46,68 @@
|
|
|
46
46
|
* })
|
|
47
47
|
* ```
|
|
48
48
|
*
|
|
49
|
+
* ## Resilience
|
|
50
|
+
*
|
|
51
|
+
* Nothing retries or reconnects implicitly — a transport behaves exactly as it
|
|
52
|
+
* always has until a policy is attached, so timing stays predictable by
|
|
53
|
+
* default. Resilience is configured on the **transport**, which owns it for
|
|
54
|
+
* every client derived from it:
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* TcpTransportService.Default({
|
|
58
|
+
* host, port,
|
|
59
|
+
* retry: RetryPolicies.tcp(), // applied to every operation
|
|
60
|
+
* reconnect: {}, // supervised reconnect + circuit breaker
|
|
61
|
+
* })
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* Policies are error-aware: transient failures (timeouts, framing errors, a
|
|
65
|
+
* busy device) back off exponentially with jitter (on by default), while
|
|
66
|
+
* deterministic ones (illegal address, invalid argument) fail immediately.
|
|
67
|
+
*
|
|
68
|
+
* Override per client — one bus, several device types — or per operation.
|
|
69
|
+
* Both replace the policy rather than composing with it:
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* const meter = yield* transport.withClient(1, { retry: RetryPolicies.serial() });
|
|
73
|
+
* yield* meter.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* With `reconnect` enabled, the transport runs one supervised reconnect for the
|
|
77
|
+
* whole application and refuses operations with {@link ModbusCircuitOpenError}
|
|
78
|
+
* while the link is down, instead of letting every caller queue requests onto a
|
|
79
|
+
* dead bus. Watch {@link ConnectionState} via `transport.connectionState`.
|
|
80
|
+
*
|
|
81
|
+
* {@link retryModbus} remains for retrying a compound operation — a
|
|
82
|
+
* read-modify-write driven as a unit — over a `RetryPolicies.none()` client.
|
|
83
|
+
* Note that it **wraps** rather than replaces: unlike the two overrides above,
|
|
84
|
+
* it is piped around an effect the client has already wrapped in its own retry,
|
|
85
|
+
* so over a policied client the two nest and attempt counts multiply.
|
|
86
|
+
*
|
|
87
|
+
* Resilience lives at this layer and only at this layer. `modbus-rs`'s own
|
|
88
|
+
* transport-level `retryAttempts` / `retryDelayMs` / `retryBackoffStrategy` are
|
|
89
|
+
* **not accepted** by any transport constructor here: they retry beneath the
|
|
90
|
+
* Effect boundary where neither the policy, the circuit breaker, nor the logs
|
|
91
|
+
* can see them, and they reconnect inline, racing the supervisor fiber that
|
|
92
|
+
* owns reconnection. See {@link UpstreamRetryOptionKey}.
|
|
93
|
+
*
|
|
49
94
|
* @module @flux-control/effect-modbus-rs
|
|
50
95
|
*/
|
|
51
96
|
export * from './src/errors';
|
|
52
97
|
export type { EffectModbusClient } from './src/modbus-client';
|
|
98
|
+
export { makeRetryPolicy, retryableExceptionCodes, RetryPolicies, retryModbus } from './src/retry';
|
|
99
|
+
export type { ModbusErrorTag, ModbusRetryPolicy, ModbusRetryPolicyOptions, RetryDelayOptions, RetryErrorOptions, } from './src/retry';
|
|
100
|
+
export { ConnectionState } from './src/connection';
|
|
101
|
+
export type { ReconnectOptions } from './src/connection';
|
|
102
|
+
export type { TransportResilienceOptions, UpstreamRetryOptionKey, WithoutUpstreamRetry, } from './src/shared-transport';
|
|
103
|
+
export type { ModbusOperations } from './src/modbus-client';
|
|
53
104
|
export { AsciiTransportService } from './src/AsciiTransportService';
|
|
105
|
+
export type { AsciiTransportOpenOptions } from './src/AsciiTransportService';
|
|
54
106
|
export { SerialTransportService } from './src/SerialTransportService';
|
|
55
107
|
export { TcpTransportService } from './src/TcpTransportService';
|
|
108
|
+
export type { TcpTransportOpenOptions } from './src/TcpTransportService';
|
|
56
109
|
export { RtuTransportService } from './src/RtuTransportService';
|
|
110
|
+
export type { RtuTransportOpenOptions } from './src/RtuTransportService';
|
|
57
111
|
export { serialRtuServerLayer, serialAsciiServerLayer } from './src/SerialModbusServerService';
|
|
58
112
|
export { tcpServerLayer } from './src/TcpModbusServerService';
|
|
59
113
|
export { tcpGatewayLayer } from './src/TcpGatewayService';
|