Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDisposeAndResolveHostTests.cs
T
Joseph Doherty 64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: backfill XML documentation across 756 files
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00

171 lines
7.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Driver.AbLegacy-011 — synchronous <see cref="IDisposable.Dispose"/> must perform real
/// synchronous teardown rather than blocking via <c>DisposeAsync().AsTask().GetAwaiter().GetResult()</c>.
/// Driver.AbLegacy-013 — <see cref="AbLegacyDriver.ResolveHost"/> fallback when the reference
/// is unknown and no devices are configured is the documented single-host fallback per the
/// <c>IPerCallHostResolver</c> contract.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDisposeAndResolveHostTests
{
// ---- Driver.AbLegacy-011 ----
/// <summary>Verifies that Dispose performs teardown without blocking on async operations.</summary>
[Fact]
public async Task Dispose_runs_teardown_without_blocking_on_async_wait()
{
// Build a driver with a real device + tag so InitializeAsync registers state, then Dispose.
// The teardown must clear the device dictionary just like ShutdownAsync would, but without
// round-tripping through AsTask().GetAwaiter().GetResult() (which would deadlock under a
// single-threaded synchronization context).
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Materialise one runtime so DisposeRuntimes has work to do.
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
drv.Dispose();
// The cached libplctag tag must be disposed and the device map cleared.
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies that Dispose can be called multiple times without throwing.</summary>
[Fact]
public async Task Dispose_is_idempotent()
{
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.Dispose();
Should.NotThrow(() => drv.Dispose());
}
/// <summary>Verifies that Dispose does not deadlock under a single-threaded synchronization context.</summary>
[Fact]
public async Task Dispose_under_single_threaded_sync_context_does_not_deadlock()
{
// The legacy sync-over-async pattern (DisposeAsync().AsTask().GetAwaiter().GetResult())
// can deadlock if any awaited continuation marshals back to a captured single-threaded
// context. Drive a single-threaded SynchronizationContext + Dispose() and ensure it
// returns within a short timeout.
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
using var ctx = new SingleThreadSynchronizationContext();
var prior = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(ctx);
try
{
var disposed = new ManualResetEventSlim(false);
ctx.Post(_ => { drv.Dispose(); disposed.Set(); }, null);
ctx.RunUntil(disposed);
disposed.IsSet.ShouldBeTrue("Dispose must return without blocking on the single-threaded context");
}
finally
{
SynchronizationContext.SetSynchronizationContext(prior);
}
}
/// <summary>
/// Minimal cooperative single-threaded SynchronizationContext for the deadlock-regression
/// test. The thread that calls <see cref="RunUntil"/> pumps queued callbacks until the
/// stop event is set.
/// </summary>
private sealed class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state) => d(state);
/// <summary>Runs the event loop until the stop signal is set.</summary>
/// <param name="stop">The event to signal loop completion.</param>
public void RunUntil(ManualResetEventSlim stop)
{
while (!stop.IsSet)
{
if (_queue.TryTake(out var item, TimeSpan.FromSeconds(2)))
item.Item1(item.Item2);
else
throw new TimeoutException("Dispose did not complete — likely sync-over-async deadlock");
}
}
/// <summary>Disposes the internal queue.</summary>
public void Dispose() => _queue.Dispose();
}
// ---- Driver.AbLegacy-013 ----
/// <summary>Verifies that ResolveHost returns the configured device for a known tag reference.</summary>
[Fact]
public void ResolveHost_known_reference_returns_tag_device()
{
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
}, "drv-1");
drv.ResolveHost("X").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost returns the first configured device when reference is unknown.</summary>
[Fact]
public void ResolveHost_unknown_reference_with_devices_returns_first_device()
{
// Multi-device fallback: an unknown reference returns the first configured device so the
// resilience pipeline keys on a real ab:// host rather than the instance id.
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices =
[
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
new AbLegacyDeviceOptions("ab://10.0.0.6/"),
],
}, "drv-1");
drv.ResolveHost("unknown").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost returns the driver instance ID when no devices are configured.</summary>
[Fact]
public void ResolveHost_unknown_reference_no_devices_returns_driver_instance_id()
{
// Per IPerCallHostResolver: implementations MUST NOT throw on an unknown reference; they
// must return the driver's default-host string. With no devices configured the driver
// instance id is the documented single-host fallback.
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-singleton");
drv.ResolveHost("anything").ShouldBe("drv-singleton");
}
}