diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
index 35417060..6bd7f9a8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
@@ -108,28 +108,28 @@ public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
GetTagsByName(drv).Count.ShouldBe(0, "Shutdown must clear the tag cache so the next Initialize starts clean");
}
- /// Verifies that reinitialization clears the deadband and write-suppression caches.
- [Fact]
- public async Task Reinitialize_clears_lastPublished_and_lastWritten_caches()
+ private static ModbusDriverOptions CacheResetOptions() => new()
{
- var fake = new FakeTransport();
- var opts = new ModbusDriverOptions
- {
- Host = "fake",
- WriteOnChangeOnly = true,
- RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
- Deadband: 1.0)]),
- };
- var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
- await drv.InitializeAsync("{}", CancellationToken.None);
+ Host = "fake",
+ WriteOnChangeOnly = true,
+ RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
+ Deadband: 1.0)]),
+ };
- var lastPublished = (System.Collections.IDictionary)typeof(ModbusDriver)
- .GetField("_lastPublishedByRef", BindingFlags.NonPublic | BindingFlags.Instance)!
- .GetValue(drv)!;
- var lastWritten = (System.Collections.IDictionary)typeof(ModbusDriver)
- .GetField("_lastWrittenByRef", BindingFlags.NonPublic | BindingFlags.Instance)!
+ private static System.Collections.IDictionary GetCache(ModbusDriver drv, string field) =>
+ (System.Collections.IDictionary)typeof(ModbusDriver)
+ .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(drv)!;
+ ///
+ /// Seeds the write-suppression cache with a write and the deadband cache through a real subscription,
+ /// waiting (bounded) for the poll engine to publish. Returns the live subscription handle.
+ ///
+ private static async Task SeedCachesAsync(ModbusDriver drv, FakeTransport fake)
+ {
+ var lastPublished = GetCache(drv, "_lastPublishedByRef");
+ var lastWritten = GetCache(drv, "_lastWrittenByRef");
+
// Seed both caches via a write (lastWritten) and a publish through ShouldPublish (lastPublished).
await drv.WriteAsync([new WriteRequest("A", (short)5)], CancellationToken.None);
lastWritten.Count.ShouldBe(1);
@@ -138,13 +138,72 @@ public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
fake.HoldingRegisters[0] = 5;
var handle = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(2);
- while (lastPublished.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
+ while (lastPublished.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25, TestContext.Current.CancellationToken);
lastPublished.Count.ShouldBe(1);
- await drv.UnsubscribeAsync(handle, CancellationToken.None);
+ return handle;
+ }
- await drv.ShutdownAsync(CancellationToken.None);
- lastPublished.Count.ShouldBe(0, "Shutdown must clear the deadband cache");
- lastWritten.Count.ShouldBe(0, "Shutdown must clear the write-suppression cache");
+ /// Verifies that reinitialization clears the deadband and write-suppression caches.
+ ///
+ /// #727: cleanup is registered before initialize, so an initialize, seeding or assertion failure still
+ /// shuts the driver down (probe loop, poll engine, transport). The in-body ShutdownAsync stays ahead of
+ /// the cache-clearing assertions it is the oracle for; the registered cleanup runs after it and is safe
+ /// because shutdown is idempotent.
+ ///
+ [Fact]
+ public async Task Reinitialize_clears_lastPublished_and_lastWritten_caches()
+ {
+ var fake = new FakeTransport();
+ var drv = new ModbusDriver(CacheResetOptions(), "modbus-1", _ => fake);
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var lastPublished = GetCache(drv, "_lastPublishedByRef");
+ var lastWritten = GetCache(drv, "_lastWrittenByRef");
+
+ var handle = await SeedCachesAsync(drv, fake);
+ await drv.UnsubscribeAsync(handle, CancellationToken.None);
+
+ await drv.ShutdownAsync(CancellationToken.None);
+ lastPublished.Count.ShouldBe(0, "Shutdown must clear the deadband cache");
+ lastWritten.Count.ShouldBe(0, "Shutdown must clear the write-suppression cache");
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"Modbus cache-reset fixture cleanup failed: {failure}"));
+ }
+
+ ///
+ /// #727 failure path: a body that fails after the subscription and probe loop are live keeps its
+ /// primary exception, and the registered cleanup still releases the fixture — the transport is
+ /// disposed once, the caches are cleared, and no further polling or probe sends land.
+ ///
+ [Fact]
+ public async Task Cache_reset_fixture_failure_after_subscribe_still_shuts_the_driver_down()
+ {
+ var fake = new FakeTransport();
+ var drv = new ModbusDriver(CacheResetOptions(), "modbus-1", _ => fake);
+ var sentinel = new InvalidOperationException("cache-reset body sentinel");
+
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ _ = await SeedCachesAsync(drv, fake);
+ fake.DisposeCount.ShouldBe(0);
+ throw sentinel; // no Unsubscribe, no Shutdown — the leak shape the fixture used to have
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"Modbus cache-reset fixture cleanup failed: {failure}")));
+
+ actual.ShouldBeSameAs(sentinel);
+ actual.Data.Contains(FixtureCleanup.SecondaryExceptionKey).ShouldBeFalse("cleanup itself must succeed");
+ fake.DisposeCount.ShouldBe(1, "cleanup must dispose the transport exactly once");
+ GetCache(drv, "_lastPublishedByRef").Count.ShouldBe(0);
+ GetCache(drv, "_lastWrittenByRef").Count.ShouldBe(0);
+ drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
+
+ // Poll engine and probe loop are both stopped: the send count is flat across several poll intervals.
+ var sendsAfterCleanup = Interlocked.CompareExchange(ref fake.SendCount, 0, 0);
+ await Task.Delay(300, TestContext.Current.CancellationToken);
+ Interlocked.CompareExchange(ref fake.SendCount, 0, 0).ShouldBe(sendsAfterCleanup,
+ "no subscription poll or probe may run after the failure-path cleanup");
}
// -------------------- Finding -004 / -012 (4) --------------------