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:
@@ -156,8 +156,10 @@ public sealed class ModbusHealthContractTests
|
||||
// ---------------- H7: write status mapping + write-side health ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_degrades_the_write_side()
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it()
|
||||
{
|
||||
// Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used
|
||||
// to latch Degraded until the next good WRITE — forever, in a read-mostly plant.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
@@ -166,16 +168,49 @@ public sealed class ModbusHealthContractTests
|
||||
w[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// A good READ does not clear a write-side fault...
|
||||
(await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_read_side_comms_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// ...a good WRITE does.
|
||||
t.Handler = null;
|
||||
(await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast()
|
||||
{
|
||||
// Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o
|
||||
? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true }
|
||||
: null!);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 9;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null;
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_leaves_health_Unknown()
|
||||
{
|
||||
// Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own.
|
||||
var (drv, _) = await StartAsync(Opts(Hr("A", 10)));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.DisposeAsync();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user