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:
Joseph Doherty
2026-10-22 15:55:32 -04:00
parent c12269c221
commit fd6ecf8293
15 changed files with 314 additions and 117 deletions
@@ -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()
{