diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs
index ebee2056..b7891d27 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReinitializeReleasesStateTests.cs
@@ -1,4 +1,5 @@
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// orphaned the previous state's probe loop + handles. It now releases them first.
///
[Trait("Category", "Unit")]
-public sealed class AbCipReinitializeReleasesStateTests
+public sealed class AbCipReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.
[Fact]
@@ -27,28 +28,94 @@ public sealed class AbCipReinitializeReleasesStateTests
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
- await drv.InitializeAsync("{}", CancellationToken.None);
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
- var first = drv.GetDeviceState(host)!;
- // Positive control: the first probe loop is running and has initialised its runtime.
+ var first = drv.GetDeviceState(host)!;
+ // Positive control: the first probe loop is running and has initialised its runtime.
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
+ await Task.Delay(10);
+ }
+ var firstTag = factory.Tags["@raw_cpu_type"];
+ var firstLoop = first.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ firstTag.Disposed.ShouldBeFalse();
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ firstLoop.IsCompleted.ShouldBeTrue(); // joined
+ firstTag.Disposed.ShouldBeTrue();
+ drv.GetDeviceState(host).ShouldNotBeSameAs(first);
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"AbCip SecondInitialize cleanup failed: {failure}"));
+ }
+
+ /// Failure after a live first or replacement state must release both owned resources.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
+ {
+ const string host = "ab://10.0.0.5/1,0";
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(host)],
+ Probe = new AbCipProbeOptions
+ {
+ Enabled = true,
+ Interval = TimeSpan.FromMilliseconds(20),
+ Timeout = TimeSpan.FromMilliseconds(200),
+ ProbeTagPath = "@raw_cpu_type",
+ },
+ }, "drv-reinit", factory);
+ Task? firstLoop = null, secondLoop = null;
+ FakeAbCipTag? firstTag = null, secondTag = null;
+ var sentinel = new InvalidOperationException("body sentinel");
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && tag.InitializeCount > 0);
+ firstTag = factory.Tags["@raw_cpu_type"];
+ firstLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ firstTag.Disposed.ShouldBeFalse();
+ if (afterReplacement)
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitForProbeAsync(() => factory.Tags.TryGetValue("@raw_cpu_type", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
+ secondTag = factory.Tags["@raw_cpu_type"];
+ secondLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = secondLoop.ShouldNotBeNull();
+ secondLoop!.IsCompleted.ShouldBeFalse();
+ }
+ throw sentinel;
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"AbCip failure fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ firstLoop!.IsCompleted.ShouldBeTrue();
+ firstTag!.Disposed.ShouldBeTrue();
+ if (afterReplacement)
+ {
+ secondLoop!.IsCompleted.ShouldBeTrue();
+ secondTag!.Disposed.ShouldBeTrue();
+ }
+ }
+
+ private static async Task WaitForProbeAsync(Func condition)
+ {
var deadline = DateTime.UtcNow.AddSeconds(5);
- while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
+ while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
- var firstTag = factory.Tags["@raw_cpu_type"];
- var firstLoop = first.ProbeTask;
- firstLoop.ShouldNotBeNull();
- firstLoop!.IsCompleted.ShouldBeFalse();
- firstTag.Disposed.ShouldBeFalse();
-
- await drv.InitializeAsync("{}", CancellationToken.None);
-
- firstLoop.IsCompleted.ShouldBeTrue(); // joined
- firstTag.Disposed.ShouldBeTrue();
- drv.GetDeviceState(host).ShouldNotBeSameAs(first);
-
- await drv.ShutdownAsync(CancellationToken.None);
}
+
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs
new file mode 100644
index 00000000..582af8b7
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FixtureCleanupTests.cs
@@ -0,0 +1,92 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Tests;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class FixtureCleanupTests
+{
+ [Fact]
+ public async Task Successful_body_runs_one_awaited_cleanup()
+ {
+ var calls = 0;
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var run = FixtureCleanup.RunAsync(() => Task.CompletedTask, async () =>
+ {
+ calls++;
+ await release.Task;
+ }, _ => throw new Exception("unexpected report"));
+
+ run.IsCompleted.ShouldBeFalse();
+ release.SetResult();
+ await run;
+ calls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Body_fault_remains_same_exception_and_original_throwing_stack()
+ {
+ var primary = new InvalidOperationException("body");
+ var cleanupCalls = 0;
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(
+ () => ThrowFromBody(primary),
+ () => { cleanupCalls++; return Task.CompletedTask; },
+ _ => throw new Exception("unexpected report")));
+
+ actual.ShouldBeSameAs(primary);
+ actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
+ cleanupCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Cleanup_only_fault_is_thrown_exactly()
+ {
+ var cleanup = new ApplicationException("cleanup");
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(
+ () => Task.CompletedTask,
+ () => Task.FromException(cleanup),
+ _ => throw new Exception("unexpected report")));
+ actual.ShouldBeSameAs(cleanup);
+ }
+
+ [Fact]
+ public async Task Both_faults_preserve_primary_and_record_secondary_once()
+ {
+ var primary = new InvalidOperationException("body");
+ var cleanup = new ApplicationException("cleanup");
+ var reported = new List();
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(
+ () => ThrowFromBody(primary),
+ () => Task.FromException(cleanup),
+ reported.Add));
+
+ actual.ShouldBeSameAs(primary);
+ actual.StackTrace.ShouldNotBeNull().ShouldContain(nameof(ThrowFromBody));
+ actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
+ reported.Count.ShouldBe(1);
+ reported[0].ShouldBeSameAs(cleanup);
+ }
+
+ [Fact]
+ public async Task Reporting_fault_does_not_replace_primary()
+ {
+ var primary = new InvalidOperationException("body");
+ var cleanup = new ApplicationException("cleanup");
+ var reportCalls = 0;
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(
+ () => ThrowFromBody(primary),
+ () => Task.FromException(cleanup),
+ _ => { reportCalls++; throw new Exception("report"); }));
+
+ actual.ShouldBeSameAs(primary);
+ actual.Data[FixtureCleanup.SecondaryExceptionKey].ShouldBeSameAs(cleanup);
+ reportCalls.ShouldBe(1);
+ }
+
+ private static async Task ThrowFromBody(Exception failure)
+ {
+ await Task.Yield();
+ throw failure;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
index 38c3d151..898e7284 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
@@ -38,4 +38,7 @@
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
index 85f3d1b5..f2d6b0df 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
@@ -1,4 +1,5 @@
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// probe loop + runtime. It now releases them first.
///
[Trait("Category", "Unit")]
-public sealed class AbLegacyReinitializeReleasesStateTests
+public sealed class AbLegacyReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.
[Fact]
@@ -23,28 +24,88 @@ public sealed class AbLegacyReinitializeReleasesStateTests
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
- await drv.InitializeAsync("{}", CancellationToken.None);
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
- var first = drv.GetDeviceState(host)!;
- // Positive control: the first state's probe loop is running and has created + initialised its runtime.
+ var first = drv.GetDeviceState(host)!;
+ // Positive control: the first state's probe loop is running and has created + initialised its runtime.
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
+ await Task.Delay(10);
+ }
+ var firstTag = factory.Tags["S:0"];
+ var firstLoop = first.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ firstTag.Disposed.ShouldBeFalse();
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ firstLoop.IsCompleted.ShouldBeTrue(); // joined
+ firstTag.Disposed.ShouldBeTrue();
+ drv.GetDeviceState(host).ShouldNotBeSameAs(first);
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"AbLegacy SecondInitialize cleanup failed: {failure}"));
+ }
+
+ /// Failure after a live first or replacement state must release both owned resources.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
+ {
+ const string host = "ab://10.0.0.5/1,0";
+ var factory = new FakeAbLegacyTagFactory();
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
+ Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
+ }, "drv-reinit", factory);
+ Task? firstLoop = null, secondLoop = null;
+ FakeAbLegacyTag? firstTag = null, secondTag = null;
+ var sentinel = new InvalidOperationException("body sentinel");
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && tag.InitializeCount > 0);
+ firstTag = factory.Tags["S:0"];
+ firstLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ firstTag.Disposed.ShouldBeFalse();
+ if (afterReplacement)
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitForProbeAsync(() => factory.Tags.TryGetValue("S:0", out var tag) && !ReferenceEquals(tag, firstTag) && tag.InitializeCount > 0);
+ secondTag = factory.Tags["S:0"];
+ secondLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = secondLoop.ShouldNotBeNull();
+ secondLoop!.IsCompleted.ShouldBeFalse();
+ }
+ throw sentinel;
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"AbLegacy failure fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ firstLoop!.IsCompleted.ShouldBeTrue();
+ firstTag!.Disposed.ShouldBeTrue();
+ if (afterReplacement)
+ {
+ secondLoop!.IsCompleted.ShouldBeTrue();
+ secondTag!.Disposed.ShouldBeTrue();
+ }
+ }
+
+ private static async Task WaitForProbeAsync(Func condition)
+ {
var deadline = DateTime.UtcNow.AddSeconds(5);
- while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
+ while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
- var firstTag = factory.Tags["S:0"];
- var firstLoop = first.ProbeTask;
- firstLoop.ShouldNotBeNull();
- firstLoop!.IsCompleted.ShouldBeFalse();
- firstTag.Disposed.ShouldBeFalse();
-
- await drv.InitializeAsync("{}", CancellationToken.None);
-
- firstLoop.IsCompleted.ShouldBeTrue(); // joined
- firstTag.Disposed.ShouldBeTrue();
- drv.GetDeviceState(host).ShouldNotBeSameAs(first);
-
- await drv.ShutdownAsync(CancellationToken.None);
}
+
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
index ae370b85..cc9bbc8c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
@@ -38,4 +38,7 @@
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
index e1687105..2e3eb6b2 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
@@ -1,4 +1,5 @@
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// probe loop + client. It now releases them first.
///
[Trait("Category", "Unit")]
-public sealed class FocasReinitializeReleasesStateTests
+public sealed class FocasReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// Initialize twice: the first state's probe loop has finished and its client is disposed.
[Fact]
@@ -25,25 +26,78 @@ public sealed class FocasReinitializeReleasesStateTests
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
- await drv.InitializeAsync("{}", CancellationToken.None);
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
- var first = drv.GetDeviceState(host)!;
- // Positive control: the first state's probe loop is demonstrably running and holds a live client.
- await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
- var firstLoop = first.ProbeTask;
- firstLoop.ShouldNotBeNull();
- firstLoop!.IsCompleted.ShouldBeFalse();
- var firstClient = factory.Clients[0];
+ var first = drv.GetDeviceState(host)!;
+ // Positive control: the first state's probe loop is demonstrably running and holds a live client.
+ await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
+ var firstLoop = first.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ var firstClient = factory.Clients[0];
- await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.InitializeAsync("{}", CancellationToken.None);
- firstLoop.IsCompleted.ShouldBeTrue(); // joined
- firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ firstLoop.IsCompleted.ShouldBeTrue(); // joined
+ firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ firstClient.IsConnected.ShouldBeFalse();
+ drv.GetDeviceState(host).ShouldNotBeSameAs(first);
+ _ = drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"FOCAS SecondInitialize cleanup failed: {failure}"));
+ }
+
+
+ /// Failure after a live first or replacement state must release both owned resources.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
+ {
+ const string host = "focas://10.0.0.5:8193";
+ var factory = new FakeFocasClientFactory();
+ var options = new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(host)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
+ Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
+ };
+ var drv = new FocasDriver(options, "focas-reinit", factory);
+ Task? firstLoop = null, secondLoop = null;
+ FakeFocasClient? firstClient = null, secondClient = null;
+ var sentinel = new InvalidOperationException("body sentinel");
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
+ firstClient = factory.Clients[0];
+ firstLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ if (afterReplacement)
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected);
+ secondClient = factory.Clients[1];
+ secondLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = secondLoop.ShouldNotBeNull();
+ secondLoop!.IsCompleted.ShouldBeFalse();
+ }
+ throw sentinel;
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"FOCAS failure fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ firstLoop!.IsCompleted.ShouldBeTrue();
+ firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
- drv.GetDeviceState(host).ShouldNotBeSameAs(first);
- drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
-
- await drv.ShutdownAsync(CancellationToken.None);
+ if (afterReplacement)
+ {
+ secondLoop!.IsCompleted.ShouldBeTrue();
+ secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ secondClient.IsConnected.ShouldBeFalse();
+ }
}
private static async Task WaitUntilAsync(Func cond)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
index c4f4be54..d8351579 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
@@ -34,4 +34,7 @@
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
index 857f606d..3e07df40 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
@@ -1,4 +1,5 @@
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -16,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
/// defect the happy-path assertions alone would pass straight over.
///
-public sealed class MTConnectDriverLifecycleTests
+public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output)
{
private const string AgentUri = "http://fixture-agent:5000";
@@ -598,18 +599,79 @@ public sealed class MTConnectDriverLifecycleTests
// same "browse must not be left permanently empty" obligation.
[Fact]
- public void Dropping_the_probe_cache_keeps_the_observation_index()
+ public async Task Dropping_the_probe_cache_keeps_the_observation_index()
{
- var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
+ var (driver, _) = NewDriver();
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await driver.InitializeAsync("{}", CancellationToken.None);
- driver.InvalidateProbeModel();
+ driver.InvalidateProbeModel();
- driver.CachedProbeModel.ShouldBeNull();
+ driver.CachedProbeModel.ShouldBeNull();
- // The index is required for correctness (it IS the read surface) — never flushable.
- driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
- driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
- driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ // The index is required for correctness (it IS the read surface) — never flushable.
+ driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
+ driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }, () => driver.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"MTConnect cache drop cleanup failed: {failure}"));
+ }
+
+ [Fact]
+ public async Task Body_failure_after_probe_scheduler_enters_stops_probe_and_disposes_client()
+ {
+ var client = CannedAgentClient.FromFixtures();
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var driver = new MTConnectDriver(Opts(), "mt1", _ => client,
+ probeScheduler: async (_, ct) =>
+ {
+ entered.TrySetResult();
+ try { await released.Task.WaitAsync(ct); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ cancelled.TrySetResult();
+ throw;
+ }
+ });
+ Task? probe = null;
+ var sentinel = new InvalidOperationException("body sentinel");
+ try
+ {
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ probe = driver.ProbeLoopTask;
+ _ = probe.ShouldNotBeNull();
+ probe!.IsCompleted.ShouldBeFalse();
+ client.IsDisposed.ShouldBeFalse();
+ throw sentinel;
+ }, () => driver.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"MTConnect probe fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ probe!.IsCompleted.ShouldBeTrue();
+ client.DisposeCount.ShouldBe(1);
+ }
+ finally { released.TrySetResult(); }
+ }
+
+ [Fact]
+ public async Task Initialization_failure_disposes_the_client_inside_fixture_boundary()
+ {
+ var client = CannedAgentClient.FromFixtures();
+ var failure = new HttpRequestException("current unavailable");
+ client.CurrentFailure = failure;
+ var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(
+ () => driver.InitializeAsync("{}", CancellationToken.None),
+ () => driver.ShutdownAsync(CancellationToken.None),
+ secondary => output.WriteLine($"MTConnect init fixture cleanup failed: {secondary}")));
+ actual.ShouldBeSameAs(failure);
+ client.DisposeCount.ShouldBe(1);
}
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj
index 866f7a9e..6b067f74 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj
@@ -39,4 +39,7 @@
+
+
+
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 316de319..35417060 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
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Reflection;
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -14,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// unit test alongside them per Driver.Modbus-012.
///
[Trait("Category", "Unit")]
-public sealed class ModbusLifecycleHygieneTests
+public sealed class ModbusLifecycleHygieneTests(ITestOutputHelper output)
{
private sealed class FakeTransport : IModbusTransport
{
@@ -528,7 +529,7 @@ public sealed class ModbusLifecycleHygieneTests
/// Verifies that DecodeBitArray rejects an empty bitmap with InvalidDataException.
[Fact]
- public void DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
+ public async Task DecodeBitArray_rejects_an_empty_bitmap_with_InvalidDataException()
{
var decode = typeof(ModbusDriver).GetMethod(
"DecodeBitArray", BindingFlags.NonPublic | BindingFlags.Static)!;
@@ -542,11 +543,49 @@ public sealed class ModbusLifecycleHygieneTests
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
};
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
- drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
- var r = drv.ReadAsync(["Coil"], CancellationToken.None).GetAwaiter().GetResult();
- // The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
- r[0].StatusCode.ShouldBe(0x80050000u);
+ var r = await drv.ReadAsync(["Coil"], CancellationToken.None);
+ // The empty-bitmap guard surfaces via the BadCommunicationError catch-all.
+ r[0].StatusCode.ShouldBe(0x80050000u);
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"Modbus empty bitmap cleanup failed: {failure}"));
+ }
+
+ [Fact]
+ public async Task Body_failure_after_probe_request_cancels_it_and_disposes_transport()
+ {
+ var fake = new EmptyBitTransport();
+ var opts = new ModbusDriverOptions
+ {
+ Host = "fake",
+ RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("Coil", ModbusRegion.Coils, 0, ModbusDataType.Bool)]),
+ };
+ var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
+ var sentinel = new InvalidOperationException("body sentinel");
+ try
+ {
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var result = await drv.ReadAsync(["Coil"], CancellationToken.None);
+ result[0].StatusCode.ShouldBe(0x80050000u);
+ fake.CoilRequestCount.ShouldBe(1);
+ await fake.ProbeEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ fake.ProbeToken.IsCancellationRequested.ShouldBeFalse();
+ fake.DisposeCount.ShouldBe(0);
+ throw sentinel;
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"Modbus probe fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ fake.ProbeToken.IsCancellationRequested.ShouldBeTrue();
+ await fake.ProbeCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await fake.ProbeRequestCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ fake.DisposeCount.ShouldBe(1);
+ }
+ finally { fake.ReleaseProbe.TrySetResult(); }
}
///
@@ -556,6 +595,14 @@ public sealed class ModbusLifecycleHygieneTests
///
private sealed class EmptyBitTransport : IModbusTransport
{
+ public TaskCompletionSource ProbeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource ProbeCancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource ProbeRequestCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource ReleaseProbe { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public CancellationToken ProbeToken { get; private set; }
+ public int DisposeCount { get; private set; }
+ public int CoilRequestCount { get; private set; }
+
/// Returns a completed task without performing any connection.
/// The cancellation token for the operation.
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
@@ -563,10 +610,29 @@ public sealed class ModbusLifecycleHygieneTests
/// The Modbus unit ID.
/// The protocol data unit being sent.
/// The cancellation token for the operation.
- public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
- => Task.FromResult(new byte[] { pdu[0], 0 });
- /// Completes the disposal without doing any work.
- public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
+ {
+ if (pdu[0] == 0x03)
+ {
+ ProbeToken = ct;
+ ProbeEntered.TrySetResult();
+ try { await ReleaseProbe.Task.WaitAsync(ct); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ ProbeCancelled.TrySetResult();
+ throw;
+ }
+ finally { ProbeRequestCompleted.TrySetResult(); }
+ }
+ if (pdu[0] == 0x01) CoilRequestCount++;
+ return new byte[] { pdu[0], 0 };
+ }
+ /// Records transport disposal.
+ public ValueTask DisposeAsync()
+ {
+ DisposeCount++;
+ return ValueTask.CompletedTask;
+ }
}
// -------------------- Finding -003 (volatile _health) --------------------
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj
index 980a367b..0dfbd27b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj
@@ -29,4 +29,7 @@
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs
index 8ef5f9eb..529f1d12 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReinitializeReleasesStateTests.cs
@@ -1,4 +1,5 @@
using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Tests;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// orphaned the previous state's probe loop + client. It now releases them first.
///
[Trait("Category", "Unit")]
-public sealed class TwinCATReinitializeReleasesStateTests
+public sealed class TwinCATReinitializeReleasesStateTests(ITestOutputHelper output)
{
/// Initialize twice: the first state's probe loop has finished and its client is disposed.
[Fact]
@@ -21,28 +22,88 @@ public sealed class TwinCATReinitializeReleasesStateTests
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
- await drv.InitializeAsync("{}", CancellationToken.None);
+ await FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
- var first = drv.GetDeviceState(host)!;
- // Positive control: the first probe loop is running and holds a connected client.
+ var first = drv.GetDeviceState(host)!;
+ // Positive control: the first probe loop is running and holds a connected client.
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
+ await Task.Delay(10);
+ }
+ var firstClient = factory.Clients[0];
+ var firstLoop = first.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ firstLoop.IsCompleted.ShouldBeTrue(); // joined
+ firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ firstClient.IsConnected.ShouldBeFalse();
+ drv.GetDeviceState(host).ShouldNotBeSameAs(first);
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"TwinCAT SecondInitialize cleanup failed: {failure}"));
+ }
+
+ /// Failure after a live first or replacement state must release both owned resources.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Body_failure_releases_live_probe_resources(bool afterReplacement)
+ {
+ const string host = "ads://5.23.91.23.1.1:851";
+ var factory = new FakeTwinCATClientFactory();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(host)],
+ Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
+ }, "drv-reinit", factory);
+ Task? firstLoop = null, secondLoop = null;
+ FakeTwinCATClient? firstClient = null, secondClient = null;
+ var sentinel = new InvalidOperationException("body sentinel");
+ var actual = await Should.ThrowAsync(() => FixtureCleanup.RunAsync(async () =>
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
+ firstClient = factory.Clients[0];
+ firstLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = firstLoop.ShouldNotBeNull();
+ firstLoop!.IsCompleted.ShouldBeFalse();
+ if (afterReplacement)
+ {
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => factory.Clients.Count > 1 && factory.Clients[1].IsConnected);
+ secondClient = factory.Clients[1];
+ secondLoop = drv.GetDeviceState(host)!.ProbeTask;
+ _ = secondLoop.ShouldNotBeNull();
+ secondLoop!.IsCompleted.ShouldBeFalse();
+ }
+ throw sentinel;
+ }, () => drv.ShutdownAsync(CancellationToken.None),
+ failure => output.WriteLine($"TwinCAT failure fixture cleanup failed: {failure}")));
+ actual.ShouldBeSameAs(sentinel);
+ firstLoop!.IsCompleted.ShouldBeTrue();
+ firstClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ firstClient.IsConnected.ShouldBeFalse();
+ if (afterReplacement)
+ {
+ secondLoop!.IsCompleted.ShouldBeTrue();
+ secondClient!.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ secondClient.IsConnected.ShouldBeFalse();
+ }
+ }
+
+ private static async Task WaitUntilAsync(Func condition)
+ {
var deadline = DateTime.UtcNow.AddSeconds(5);
- while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
+ while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
- var firstClient = factory.Clients[0];
- var firstLoop = first.ProbeTask;
- firstLoop.ShouldNotBeNull();
- firstLoop!.IsCompleted.ShouldBeFalse();
-
- await drv.InitializeAsync("{}", CancellationToken.None);
-
- firstLoop.IsCompleted.ShouldBeTrue(); // joined
- firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
- firstClient.IsConnected.ShouldBeFalse();
- drv.GetDeviceState(host).ShouldNotBeSameAs(first);
-
- await drv.ShutdownAsync(CancellationToken.None);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj
index fd7dacec..8b1dbba6 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj
@@ -29,4 +29,7 @@
+
+
+
diff --git a/tests/TestSupport/FixtureCleanup.cs b/tests/TestSupport/FixtureCleanup.cs
new file mode 100644
index 00000000..4cdaac85
--- /dev/null
+++ b/tests/TestSupport/FixtureCleanup.cs
@@ -0,0 +1,32 @@
+namespace ZB.MOM.WW.OtOpcUa.Tests;
+
+internal static class FixtureCleanup
+{
+ internal const string SecondaryExceptionKey = "FixtureCleanup.SecondaryException";
+
+ internal static async Task RunAsync(Func body, Func cleanup, Action report)
+ {
+ Exception? primary = null;
+ try
+ {
+ await body();
+ }
+ catch (Exception failure)
+ {
+ primary = failure;
+ throw;
+ }
+ finally
+ {
+ try
+ {
+ await cleanup();
+ }
+ catch (Exception secondary) when (primary is not null)
+ {
+ try { primary.Data[SecondaryExceptionKey] = secondary; } catch { }
+ try { report(secondary); } catch { }
+ }
+ }
+ }
+}