Task #151 — Modbus coalescing: periodic re-probe of auto-prohibitions

#148 introduced auto-prohibited coalesced ranges that persist for the
driver lifetime. Long-running deployments with transient PLC permission
changes (firmware update unlocking a previously-protected register,
operator reconfiguring the device) had no recovery short of operator
restart.

Adds an opt-in background loop that re-probes each prohibition periodically:

- ModbusDriverOptions.AutoProhibitReprobeInterval (TimeSpan?, default null
  = disabled). Set to e.g. TimeSpan.FromHours(1) to opt in.
- _autoProhibited refactored from HashSet<key> to Dictionary<key, DateTime>
  so each entry tracks its last failure / last re-probe timestamp.
- ReprobeLoopAsync runs on the same Task.Run pattern as ProbeLoopAsync;
  cancelled by ShutdownAsync. Each tick snapshots the prohibition set
  and issues a one-shot coalesced read per range. Successful re-probes
  drop the prohibition; failed ones bump the timestamp + leave the
  prohibition in place.
- Communication failures during re-probe (transport-level) are treated
  the same as PLC-exception failures — the prohibition stays, but isn't
  upgraded to "permanent" since transports recover. The driver-instance
  health surface picks up the failure separately.
- ShutdownAsync explicitly clears the prohibition set so a manual restart
  via ReinitializeAsync starts with a clean slate (matches the old
  "restart to clear" semantics).
- Factory DTO + JSON binding extended with AutoProhibitReprobeMs field.

Tests (2 new, additive to the 3 in ModbusCoalescingAutoRecoveryTests):
- Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy — protected
  register at 102 records prohibition; clearing the simulated protection
  + invoking the re-probe drops the prohibition.
- Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad — re-probe on a
  still-failing range keeps the prohibition in place.

Tests use a new internal RunReprobeOnceForTestAsync helper to fire one
re-probe pass synchronously, so the suite doesn't have to wait on the
background timer (the loop's timer behaviour is exercised implicitly via
the InitializeAsync wire-up + the synchronous helper sharing the actual
re-probe code path).

234 + 2 = 236 unit tests green.
This commit is contained in:
Joseph Doherty
2026-04-25 01:12:48 -04:00
parent 8de152df4f
commit 9e4aae350b
4 changed files with 159 additions and 3 deletions

View File

@@ -104,6 +104,58 @@ public sealed class ModbusCoalescingAutoRecoveryTests
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy()
{
// #151 — when AutoProhibitReprobeInterval is set, the background loop retries each
// prohibition periodically. We exercise that via the test-only RunReprobeOnceForTestAsync
// helper rather than waiting for the timer (which would slow the suite).
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1: coalesced read fails, prohibition recorded.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Operator unlocks the protected register at the PLC (firmware update etc.). The
// re-probe should now succeed and clear the prohibition.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(0, "re-probe must clear the prohibition once the range is healthy");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe with the protected register still bad — prohibition stays.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "re-probe failure must keep the prohibition in place");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
{