fix(modbus): refusals are per-tag Bad, batch health, generation-stamped lifecycle, write status mapping
H4: exception 01/02/03 (and undecodable data) are device refusals - per-tag Bad with a once-per-tag Warning, no Degraded, and they advance LastSuccessfulRead (the device answered), so an all-refused driver no longer trips the 90 s watchdog. 04/05/06 still degrade (answered); 0A/0B degrade without advancing. Health is one worst-wins write per batch instead of last-writer-wins per tag. A comms failure on one unit fails the rest of that unit's batch fast; an unreachable endpoint fails the whole batch fast. H7: write transport failures map to BadCommunicationError, value overflow and array length mismatch to BadOutOfRange, conversion failures to BadTypeMismatch; writes report write-side health (a good write or a refusal clears a write-side Degraded; a good read does not). Lifecycle: every health write and the transport/table publish carry a generation; a superseded init releases its transport and publishes nothing, a read straddling a re-init cannot degrade the new transport, and the shutdown's final write cannot clobber a newer init. The tag table is build-and-swapped (re-init no longer merges into the old table). Teardown joins the probe/re-probe loops (bounded); a probe answer advances LastSuccessfulRead and an exception PDU from the device counts as Running. Transports latch dispose atomically and never reconnect after it. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -76,6 +76,7 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
{
|
||||
// A previous socket (failed, torn down or idle-reaped) is released FIRST: overwriting _client
|
||||
// used to strand the old TcpClient for the finalizer — up to nine per backoff loop.
|
||||
ThrowIfDisposed();
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
|
||||
// DNS resolution runs under the same per-op deadline as the TCP connect: an unresolvable or
|
||||
@@ -98,6 +99,7 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(client, _keepAlive);
|
||||
await client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
ThrowIfDisposed(); // disposed while dialling — the finally releases the fresh client
|
||||
_stream = client.GetStream();
|
||||
_client = client;
|
||||
client = null; // ownership transferred
|
||||
@@ -258,11 +260,16 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
// Latch first: a SendAsync still in flight (the driver tearing down under it) must not reconnect
|
||||
// a fresh socket after this point — ConnectAsync checks the latch both before and after dialling.
|
||||
Volatile.Write(ref _disposed, 1);
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private int _disposed;
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user