fix(modbus): dispose the previous transport and probe loops on re-initialize

This commit is contained in:
Joseph Doherty
2026-08-15 21:37:28 -04:00
parent dc459633e0
commit c3970a2e5d
2 changed files with 111 additions and 0 deletions
@@ -218,6 +218,93 @@ public sealed class ModbusLifecycleHygieneTests
"DisposeAsync must dispose the PollGroupEngine so its background Task stops, not just the transport");
}
// -------------------- Re-initialize transport hygiene --------------------
/// <summary>
/// A second <c>InitializeAsync</c> on a live driver — the shape
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> produces when an ApplyDelta lands while the
/// driver is Connecting/Reconnecting — must dispose the transport the previous initialize
/// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten,
/// stranding a connected transport no later dispose could reach.
/// </summary>
[Fact]
public async Task A_second_InitializeAsync_disposes_the_first_transport()
{
var first = new FakeTransport();
var second = new FakeTransport();
var handed = new List<FakeTransport>();
var opts = new ModbusDriverOptions
{
Host = "fake",
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ =>
{
var next = handed.Count == 0 ? first : second;
handed.Add(next);
return next;
});
await drv.InitializeAsync("{}", CancellationToken.None);
// No ShutdownAsync in between — exactly what AdoptConfigDuringInit does.
await drv.InitializeAsync("{}", CancellationToken.None);
handed.Count.ShouldBe(2, "each initialize must build its own transport");
first.DisposeCount.ShouldBe(1, "the first initialize's transport must be disposed by the second initialize");
second.DisposeCount.ShouldBe(0, "the live transport must not be disposed");
// …and the driver is still functional, serving reads through the SECOND transport.
drv.GetHealth().State.ShouldNotBe(DriverState.Faulted);
second.HoldingRegisters[0] = 7;
var r = await drv.ReadAsync(["A"], CancellationToken.None);
r[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[0].Value).ShouldBe(7);
second.SendCount.ShouldBeGreaterThan(0, "reads must route through the transport the second initialize published");
}
/// <summary>
/// The probe / re-probe CancellationTokenSources are re-assigned by every initialize, so a
/// second initialize must cancel the previous pair or their background loops run forever
/// against a transport that is already gone.
/// </summary>
[Fact]
public async Task A_second_InitializeAsync_stops_the_first_probe_and_reprobe_loops()
{
var first = new FakeTransport();
var second = new FakeTransport();
var handed = 0;
var opts = new ModbusDriverOptions
{
Host = "fake",
Probe = new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromSeconds(1),
},
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(50),
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => Interlocked.Increment(ref handed) == 1 ? first : second);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(200);
Interlocked.CompareExchange(ref first.SendCount, 0, 0).ShouldBeGreaterThan(0,
"the first initialize's probe loop should have issued at least one send");
await drv.InitializeAsync("{}", CancellationToken.None);
// The first transport's loops must stop; tolerate one in-flight send straddling the cancel.
var atReinit = Interlocked.CompareExchange(ref first.SendCount, 0, 0);
await Task.Delay(400);
var atRest = Interlocked.CompareExchange(ref first.SendCount, 0, 0);
(atRest - atReinit).ShouldBeLessThanOrEqualTo(1,
"the previous initialize's probe/re-probe loops must be cancelled by the next initialize");
await drv.DisposeAsync();
}
// -------------------- Finding -005 / -012 (3) --------------------
/// <summary>