fix(modbus): review r0 - FC23 opt-in, explicit maxReadGap 0 = off, legacy coil types load, one health writer
Coordinator rulings and Important findings from review r0: 1. FC23 is opt-in (new UseFC23ForMultiRegisterWrites, default false); a config without it - including the persisted "disableFC23": false every form-saved config carries - keeps the FC16 wire. DisableFC23 stays the hard kill-switch. 2. An explicit maxReadGap 0 means coalescing off again (its pre-lane meaning); the gap-8 default applies only to an absent key. 3. Coils/DiscreteInputs tags persisted with a register dataType (the old editor saved Int16) are coerced to Bool, never refused, with no deploy warning (a warning can be promoted to a blocking error). The editor loads them as Bool. Register region + Bool is still refused. 4. One comms-fault slot for reads and writes: a good read clears a write's transport fault and vice versa. Minors: Dispose writes its own Unknown health (m1); persisted region/address are authoritative over addressString so a Family change cannot remap a live tag (m2); a malformed-but-delivered reply no longer fails the unit fast (m3); a fresh socket published after a concurrent dispose is torn down (m4); the editor resets address text per tag (m5); master merged (m6); the FC23 latch log says re-init restores it (m7). Back-compat tests use the old persisted JSON shapes and fail without the fixes. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -61,11 +61,12 @@ public sealed class ModbusDriver
|
||||
/// <summary>Guards <see cref="_generation"/>, the transport/table publish and the health fields.</summary>
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
// Health is composed from two independent sides so a good write clears only a WRITE-side
|
||||
// Degraded (and a good read only a read-side one). LastSuccessfulRead advances on any evidence the
|
||||
// device answered — a good read, a good write, a refusal, or a probe answer.
|
||||
private string? _readFault;
|
||||
private string? _writeFault;
|
||||
// ONE comms-fault slot for reads AND writes: a fault is a property of the shared transport, so the
|
||||
// latest batch of either kind decides — a good read clears a Degraded a write's timeout raised and vice
|
||||
// versa (two independent sides latched a single write blip as Degraded forever in a read-mostly plant).
|
||||
// LastSuccessfulRead advances on any evidence the device answered — a good read, a good write, a
|
||||
// refusal, or a probe answer.
|
||||
private string? _commsFault;
|
||||
|
||||
// Once-per-tag Warning for deterministic device refusals (exception 01/02/03, undecodable data).
|
||||
// Replaced (never cleared in place) on every table publish.
|
||||
@@ -246,16 +247,13 @@ public sealed class ModbusDriver
|
||||
// failure is the re-initialize window itself, which the lifecycle reports on its own.
|
||||
var current = _health;
|
||||
if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
|
||||
_readFault = ex.Message;
|
||||
_commsFault = ex.Message;
|
||||
PublishComposedHealthLocked(current.LastSuccessfulRead);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- health composition ----
|
||||
|
||||
/// <summary>Which half of the driver a batch outcome came from.</summary>
|
||||
private enum HealthSide { Read, Write }
|
||||
|
||||
/// <summary>
|
||||
/// Per-batch accounting: did the device answer anything, did a comms fault happen, and which units
|
||||
/// (or the whole endpoint) must the rest of this batch fail fast against.
|
||||
@@ -277,7 +275,7 @@ public sealed class ModbusDriver
|
||||
/// <paramref name="generation"/> is still current. A batch that touched no device (every reference
|
||||
/// unknown, every write refused before the wire) changes nothing.
|
||||
/// </summary>
|
||||
private void ReportOutcome(int generation, HealthSide side, BatchOutcome batch, DateTime now)
|
||||
private void ReportOutcome(int generation, BatchOutcome batch, DateTime now)
|
||||
{
|
||||
if (!batch.Answered && batch.CommsFault is null) return;
|
||||
lock (_stateLock)
|
||||
@@ -285,16 +283,15 @@ public sealed class ModbusDriver
|
||||
if (generation != _generation) return;
|
||||
var current = _health;
|
||||
if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
|
||||
if (side == HealthSide.Read) _readFault = batch.CommsFault;
|
||||
else _writeFault = batch.CommsFault;
|
||||
_commsFault = batch.CommsFault;
|
||||
PublishComposedHealthLocked(batch.Answered ? now : current.LastSuccessfulRead);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes Healthy/Degraded from the two fault sides. Caller holds <see cref="_stateLock"/>.</summary>
|
||||
/// <summary>Writes Healthy/Degraded from the comms-fault slot. Caller holds <see cref="_stateLock"/>.</summary>
|
||||
private void PublishComposedHealthLocked(DateTime? lastSuccessfulRead)
|
||||
{
|
||||
var fault = _readFault ?? _writeFault;
|
||||
var fault = _commsFault;
|
||||
WriteHealth(fault is null
|
||||
? new DriverHealth(DriverState.Healthy, lastSuccessfulRead, null)
|
||||
: new DriverHealth(DriverState.Degraded, lastSuccessfulRead, fault));
|
||||
@@ -321,8 +318,7 @@ public sealed class ModbusDriver
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (generation != _generation) return;
|
||||
_readFault = null;
|
||||
_writeFault = null;
|
||||
_commsFault = null;
|
||||
WriteHealth(health);
|
||||
}
|
||||
}
|
||||
@@ -370,7 +366,10 @@ public sealed class ModbusDriver
|
||||
return StatusBadCommunicationError;
|
||||
default:
|
||||
batch.CommsFault ??= ex.Message;
|
||||
batch.FailUnit(unit);
|
||||
// Fail the unit fast only when nothing came back in time (timeout / desync / socket-level IO):
|
||||
// a malformed-but-delivered reply (byte-count mismatch) proves the unit responds, so its
|
||||
// siblings are still tried.
|
||||
if (ex is IOException or TimeoutException or OperationCanceledException) batch.FailUnit(unit);
|
||||
return StatusBadCommunicationError;
|
||||
}
|
||||
}
|
||||
@@ -493,8 +492,7 @@ public sealed class ModbusDriver
|
||||
generation = ++_generation;
|
||||
previous = _transport;
|
||||
_transport = null;
|
||||
_readFault = null;
|
||||
_writeFault = null;
|
||||
_commsFault = null;
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
}
|
||||
|
||||
@@ -728,7 +726,9 @@ public sealed class ModbusDriver
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
var batch = new BatchOutcome();
|
||||
|
||||
var coalesced = !_options.DisableReadCoalescing
|
||||
// MaxReadGap 0 = coalescing OFF (its pre-lane meaning, kept for every persisted config that authored
|
||||
// it); the gap-8 default applies only to an absent key.
|
||||
var coalesced = !_options.DisableReadCoalescing && _options.MaxReadGap > 0
|
||||
? await ReadCoalescedAsync(transport, table, fullReferences, results, now, batch, cancellationToken).ConfigureAwait(false)
|
||||
: new HashSet<int>();
|
||||
|
||||
@@ -774,7 +774,7 @@ public sealed class ModbusDriver
|
||||
|
||||
// ONE health write per batch, worst-wins over the batch, generation-checked. It used to be
|
||||
// last-writer-wins per tag, so health flapped with tag order.
|
||||
ReportOutcome(generation, HealthSide.Read, batch, now);
|
||||
ReportOutcome(generation, batch, now);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1503,9 +1503,9 @@ public sealed class ModbusDriver
|
||||
}
|
||||
}
|
||||
|
||||
// Write-side health: a comms failure degrades it (LastSuccessfulRead preserved); a good write or a
|
||||
// device refusal proves the device answered and clears a write-side Degraded. Generation-checked.
|
||||
ReportOutcome(generation, HealthSide.Write, batch, DateTime.UtcNow);
|
||||
// Write outcome feeds the same single health slot as reads: a comms failure degrades (LastSuccessfulRead
|
||||
// preserved); a good write or a device refusal proves the device answered and clears it. Generation-checked.
|
||||
ReportOutcome(generation, batch, DateTime.UtcNow);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1665,7 +1665,8 @@ public sealed class ModbusDriver
|
||||
/// <summary>
|
||||
/// Multi-register write via FC23 (Read/Write Multiple Registers): the device writes the block and
|
||||
/// returns the SAME block read back in one transaction, so the write is confirmed by the device's own
|
||||
/// post-write registers. Gated by <see cref="ModbusDriverOptions.DisableFC23"/>. A device that does
|
||||
/// post-write registers. OPT-IN via <see cref="ModbusDriverOptions.UseFC23ForMultiRegisterWrites"/>,
|
||||
/// and <see cref="ModbusDriverOptions.DisableFC23"/> is the hard kill-switch. A device that does
|
||||
/// not implement FC23 (MELSEC QJ71MT91, S7 Modbus servers — exception 01) falls back to FC16 for this
|
||||
/// write and every later one on that unit, which is safe because an Illegal Function wrote nothing.
|
||||
/// A transport failure on FC23 is reported (its outcome is unknown, so it is not retried as FC16) and
|
||||
@@ -1681,7 +1682,8 @@ public sealed class ModbusDriver
|
||||
var unsupported = Volatile.Read(ref _fc23Unsupported);
|
||||
// Only genuinely multi-register writes: a single register forced onto FC16 (UseFC16ForSingleRegisterWrites)
|
||||
// stays on the function code the operator named.
|
||||
if (_options.DisableFC23 || qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
|
||||
if (!_options.UseFC23ForMultiRegisterWrites || _options.DisableFC23
|
||||
|| qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
|
||||
|
||||
var pdu = new byte[10 + 1 + bytes.Length];
|
||||
pdu[0] = 0x17;
|
||||
@@ -1701,7 +1703,7 @@ public sealed class ModbusDriver
|
||||
{
|
||||
if (unsupported.TryAdd(unit, 0))
|
||||
_logger.LogInformation(
|
||||
"Modbus unit {Unit} does not implement FC23 (Illegal Function); multi-register writes use FC16. Driver={DriverInstanceId}",
|
||||
"Modbus unit {Unit} does not implement FC23 (Illegal Function); multi-register writes use FC16 until the driver is re-initialized. Driver={DriverInstanceId}",
|
||||
unit, _driverInstanceId);
|
||||
return false;
|
||||
}
|
||||
@@ -1709,7 +1711,7 @@ public sealed class ModbusDriver
|
||||
{
|
||||
if (unsupported.TryAdd(unit, 0))
|
||||
_logger.LogWarning(ex,
|
||||
"Modbus FC23 write to unit {Unit} failed at the transport; later multi-register writes to it use FC16. Driver={DriverInstanceId}",
|
||||
"Modbus FC23 write to unit {Unit} failed at the transport; later multi-register writes to it use FC16 until the driver is re-initialized (a redeploy or reconnect restores FC23). Driver={DriverInstanceId}",
|
||||
unit, _driverInstanceId);
|
||||
throw;
|
||||
}
|
||||
@@ -2318,7 +2320,13 @@ public sealed class ModbusDriver
|
||||
// Dispose supersedes any in-flight (abandoned) initialize up front, even when it then has to
|
||||
// bypass the gate: that initialize will find its generation moved on and release its own
|
||||
// transport instead of publishing it after this teardown.
|
||||
lock (_stateLock) _generation++;
|
||||
int generation;
|
||||
DateTime? lastRead;
|
||||
lock (_stateLock)
|
||||
{
|
||||
generation = ++_generation;
|
||||
lastRead = _health.LastSuccessfulRead;
|
||||
}
|
||||
if (await _lifecycleGate.WaitAsync(LifecycleGateTimeout).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
@@ -2329,14 +2337,18 @@ public sealed class ModbusDriver
|
||||
{
|
||||
_lifecycleGate.Release();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Modbus lifecycle gate still held at dispose; tearing the transport down anyway. Driver={DriverInstanceId}",
|
||||
_driverInstanceId);
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Modbus lifecycle gate still held at dispose; tearing the transport down anyway. Driver={DriverInstanceId}",
|
||||
_driverInstanceId);
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
// Dispose's own final health write. Bumping the generation above drops a still-running shutdown's
|
||||
// Unknown write, so without this a disposed driver kept reporting whatever it last was (e.g. Healthy).
|
||||
WriteLifecycleHealth(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user