@flux-control/effect-modbus-rs 0.5.0 → 0.6.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 CHANGED
@@ -711,6 +711,32 @@ Effect.gen(function* () {
711
711
  });
712
712
  ```
713
713
 
714
+ ### Connect timeout
715
+
716
+ The `modbus-rs` bindings do not limit the time of a connect. A TCP connect to a host that does not answer waits until the operating system stops it. On Linux with default settings, this takes about 2 minutes. During that time, every operation joins the same pending connect.
717
+
718
+ Set `connectTimeout` to limit how long each operation waits for an open or a reconnect:
719
+
720
+ ```ts
721
+ TcpTransportService.make({
722
+ host: '192.0.2.1',
723
+ port: 502,
724
+ connectTimeout: '3 seconds', // limit for each wait on an open or reconnect
725
+ reconnect: {},
726
+ });
727
+ ```
728
+
729
+ The limit applies to each operation that waits for an open or reconnect. When it expires, that operation fails with `ModbusTimeoutError`.
730
+
731
+ The native connect cannot be cancelled, so it continues after the timeout:
732
+
733
+ - Later operations wait for the same native connect, each for at most the limit. A transport never has more than one pending native connect.
734
+ - When the native connect succeeds later, the transport uses it. A late reconnect that succeeds after the supervisor published `Down` publishes `Connected` at once.
735
+ - When the native connect fails, the next operation starts a new connect.
736
+ - A supervised reconnect attempt that times out is retried by the reconnect policy by default. When the attempts are exhausted, the transport publishes `Down`.
737
+
738
+ The option applies to every transport. It is unbounded by default. `requestTimeoutMs` limits each request, but it does not limit the connect.
739
+
714
740
  ### Retrying a transaction
715
741
 
716
742
  `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.
package/dist/index.js CHANGED
@@ -1250,7 +1250,7 @@ var createMockTransport = (devices) => {
1250
1250
  };
1251
1251
 
1252
1252
  // src/shared-transport.ts
1253
- import { Deferred as Deferred4, Effect as Effect8, Exit as Exit4, Option, Ref, Scope as Scope3, SubscriptionRef as SubscriptionRef4 } from "effect";
1253
+ import { Deferred as Deferred4, Duration as Duration6, Effect as Effect8, Exit as Exit4, Option, Ref, Scope as Scope3, SubscriptionRef as SubscriptionRef4 } from "effect";
1254
1254
  var makeInFlight = () => Ref.make(Option.none());
1255
1255
  var singleFlight = (inFlight, work) => Effect8.uninterruptibleMask((restore) => Effect8.gen(function* () {
1256
1256
  const fresh = yield* Deferred4.make();
@@ -1292,9 +1292,11 @@ var loadTransportConstructor = async (transportKey, moduleSpecifier) => {
1292
1292
  function createTransportScoped(transportKey, openMethod, serviceName, config) {
1293
1293
  return Effect8.fnUntraced(function* (options) {
1294
1294
  const { retry: transportRetry, reconnect: reconnectOptions } = options;
1295
+ const connectTimeout = options.connectTimeout === undefined ? undefined : Duration6.fromInputUnsafe(options.connectTimeout);
1295
1296
  const openOptions = { ...options };
1296
1297
  Reflect.deleteProperty(openOptions, "retry");
1297
1298
  Reflect.deleteProperty(openOptions, "reconnect");
1299
+ Reflect.deleteProperty(openOptions, "connectTimeout");
1298
1300
  const TC = yield* Effect8.promise(() => loadTransportConstructor(transportKey, config?.moduleSpecifier ?? "modbus-rs"));
1299
1301
  let transport = null;
1300
1302
  const opening = yield* makeInFlight();
@@ -1310,6 +1312,15 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1310
1312
  message: "Transport has been closed"
1311
1313
  });
1312
1314
  const closeOrphan = (t) => Effect8.forkDetach(Effect8.ignore(Effect8.tryPromise(() => t.close())));
1315
+ const limitConnect = (wait) => {
1316
+ if (connectTimeout === undefined)
1317
+ return wait;
1318
+ const message = `${serviceName}: connect did not complete within ${Duration6.format(connectTimeout)}`;
1319
+ return Effect8.timeoutOrElse(wait, {
1320
+ duration: connectTimeout,
1321
+ orElse: () => Effect8.fail(new ModbusTimeoutError({ cause: new Error(message), message }))
1322
+ });
1323
+ };
1313
1324
  const openTransport = Effect8.tryPromise({
1314
1325
  try: () => openMethod(TC, openOptions),
1315
1326
  catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
@@ -1321,7 +1332,7 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1321
1332
  return yield* transportClosed();
1322
1333
  if (transport)
1323
1334
  return transport;
1324
- const t = yield* singleFlight(opening, openTransport);
1335
+ const t = yield* limitConnect(singleFlight(opening, openTransport));
1325
1336
  if (closed)
1326
1337
  return yield* transportClosed();
1327
1338
  return t;
@@ -1340,14 +1351,15 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1340
1351
  });
1341
1352
  yield* Effect8.addFinalizer(() => Effect8.orDie(closeTransport));
1342
1353
  const notConnectedMsg = "Transport is not connected. Call withClient() first.";
1354
+ const reconnectTransport = (t) => limitConnect(singleFlight(reconnecting, Effect8.tryPromise({
1355
+ try: () => t.reconnect(),
1356
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1357
+ }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : SubscriptionRef4.update(connectionState, (current) => ConnectionState.$is("Down")(current) ? ConnectionState.Connected() : current)))));
1343
1358
  const reconnectOnce = Effect8.suspend(() => {
1344
1359
  const t = transport;
1345
1360
  if (closed || !t)
1346
1361
  return transportClosed();
1347
- return singleFlight(reconnecting, Effect8.tryPromise({
1348
- try: () => t.reconnect(),
1349
- catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1350
- }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
1362
+ return reconnectTransport(t);
1351
1363
  });
1352
1364
  const report = (error) => {
1353
1365
  if (closed || !resolvedReconnect.triggers(error))
@@ -1424,10 +1436,7 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1424
1436
  yield* ensureOpen();
1425
1437
  return;
1426
1438
  }
1427
- yield* singleFlight(reconnecting, Effect8.tryPromise({
1428
- try: () => t.reconnect(),
1429
- catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1430
- }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
1439
+ yield* reconnectTransport(t);
1431
1440
  if (closed)
1432
1441
  return yield* transportClosed();
1433
1442
  yield* SubscriptionRef4.set(connectionState, ConnectionState.Connected());
@@ -30,6 +30,9 @@ declare const ModbusTimeoutError_base: new <A extends Record<string, any> = {}>(
30
30
  * with {@link RtuTransportOptions.requestTimeoutMs | requestTimeoutMs} /
31
31
  * {@link RtuTransportOptions.responseTimeoutMs | responseTimeoutMs}.
32
32
  *
33
+ * The transport also raises this error locally when an operation waits for an
34
+ * open or reconnect longer than the `connectTimeout` transport option.
35
+ *
33
36
  * @see ModbusErrorCode.TIMEOUT — `modbus-rs` error code that triggers this error.
34
37
  */
35
38
  export declare class ModbusTimeoutError extends ModbusTimeoutError_base<{
@@ -1,4 +1,4 @@
1
- import { Effect, Scope, SubscriptionRef } from 'effect';
1
+ import { Duration, Effect, Scope, SubscriptionRef } from 'effect';
2
2
  import type { AsyncAsciiTransport, AsyncRtuTransport, AsyncTcpTransport } from 'modbus-rs';
3
3
  import type { WasmAsciiTransport, WasmRtuTransport, WasmWsTransport } from 'modbus-rs/web';
4
4
  import { type BatchingClientOptions, type BatchingModbusClient } from './batching-client';
@@ -24,6 +24,24 @@ export interface TransportResilienceOptions {
24
24
  * enables the circuit breaker that keeps callers off a dead bus.
25
25
  */
26
26
  readonly reconnect?: ReconnectOptions;
27
+ /**
28
+ * Time limit for each operation that waits for an open or reconnect of the
29
+ * underlying transport.
30
+ *
31
+ * The `modbus-rs` bindings do not limit the connect time. A TCP connect to a
32
+ * host that does not answer waits until the operating system stops it, which
33
+ * takes about 2 minutes on Linux with default settings.
34
+ *
35
+ * The limit applies to each caller that waits for an open or reconnect.
36
+ * When it expires, that caller fails with {@link ModbusTimeoutError}.
37
+ *
38
+ * The native connect cannot be cancelled, so it continues. Later callers wait
39
+ * for the same native connect, each for at most this limit, so one transport
40
+ * has at most one pending native connect. When the native connect succeeds
41
+ * later, the transport uses it. When it fails, the next caller starts a new
42
+ * connect. Unbounded by default.
43
+ */
44
+ readonly connectTimeout?: Duration.Input;
27
45
  }
28
46
  /**
29
47
  * The `modbus-rs` transport-level retry knobs this package deliberately does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flux-control/effect-modbus-rs",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Type-safe Modbus communication via Effect-TS, wrapping the modbus-rs npm bindings.",
5
5
  "license": "GPL-3.0",
6
6
  "repository": {