Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.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

299 lines
13 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
[Trait("Category", "Unit")]
public sealed class DriverHostTests
{
private sealed class StubDriver(string id, bool failInit = false) : IDriver
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Stub";
/// <summary>Gets a value indicating whether the driver has been initialized.</summary>
public bool Initialized { get; private set; }
/// <summary>Gets a value indicating whether the driver has been shut down.</summary>
public bool ShutDown { get; private set; }
/// <summary>Initializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in stub).</param>
/// <param name="ct">The cancellation token.</param>
public Task InitializeAsync(string _, CancellationToken ct)
{
if (failInit) throw new InvalidOperationException("boom");
Initialized = true;
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in stub).</param>
/// <param name="ct">The cancellation token.</param>
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
[Fact]
public async Task Register_initializes_driver_and_tracks_health()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
host.RegisteredDriverIds.ShouldContain("d-1");
driver.Initialized.ShouldBeTrue();
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that registration rethrows initialization failures but keeps the driver registered.</summary>
[Fact]
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-bad", failInit: true);
await Should.ThrowAsync<InvalidOperationException>(() =>
host.RegisterAsync(driver, "{}", CancellationToken.None));
host.RegisteredDriverIds.ShouldContain("d-bad");
}
/// <summary>Verifies that duplicate driver registration throws an exception.</summary>
[Fact]
public async Task Duplicate_registration_throws()
{
await using var host = new DriverHost();
await host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() =>
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
}
/// <summary>Verifies that unregistering a driver shuts it down and removes it.</summary>
[Fact]
public async Task Unregister_shuts_down_and_removes()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
await host.UnregisterAsync("d-1", CancellationToken.None);
host.RegisteredDriverIds.ShouldNotContain("d-1");
driver.ShutDown.ShouldBeTrue();
}
/// <summary>
/// Core-004 regression — DriverHost is a library type whose async calls must use
/// ConfigureAwait(false) to match the convention used by CapabilityInvoker /
/// AlarmSurfaceInvoker. Asserts the awaited driver call does not post its
/// continuation back to a captured SynchronizationContext.
/// The driver awaits an unsettled TaskCompletionSource so it does not introduce its
/// own capture — only DriverHost's await of the returned Task can drive a post.
/// </summary>
[Fact]
public async Task RegisterAsync_Does_Not_Capture_SynchronizationContext()
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-1", tcs);
var ctx = new TrackingSynchronizationContext();
// Run the DriverHost call on a dedicated thread that has our tracking SyncContext installed.
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
var registerTask = host.RegisterAsync(driver, "{}", CancellationToken.None);
// Complete the driver's InitializeAsync from a background thread so DriverHost's
// await must resume via the captured context if ConfigureAwait(false) was missing.
_ = Task.Run(() => tcs.SetResult());
await registerTask.ConfigureAwait(false);
await host.DisposeAsync().ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
}
/// <summary>Verifies that UnregisterAsync does not capture the synchronization context.</summary>
[Fact]
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-2", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
// After RegisterAsync we re-enter the context. Reset the post counter so we only
// observe UnregisterAsync's behaviour from here on.
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.UnregisterAsync("d-cfg-2", CancellationToken.None);
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
}
/// <summary>Verifies that DisposeAsync does not capture the synchronization context.</summary>
[Fact]
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-3", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.DisposeAsync();
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"DisposeAsync's awaited shutdown call must use ConfigureAwait(false)");
}
/// <summary>
/// Run <paramref name="body"/> on a dedicated thread with <paramref name="ctx"/>
/// installed as the current SynchronizationContext, and return <paramref name="ctx"/>
/// after the body completes. The dedicated thread guarantees that resuming via the
/// captured context observably routes through our Post hook (the ThreadPool would
/// otherwise clear the context on the resuming worker).
/// </summary>
private static Task<TrackingSynchronizationContext> RunOnContextAsync(TrackingSynchronizationContext ctx, Func<Task> body)
{
var done = new TaskCompletionSource<TrackingSynchronizationContext>(TaskCreationOptions.RunContinuationsAsynchronously);
var t = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(ctx);
try
{
// Pump posted continuations until the body completes.
var task = body();
while (!task.IsCompleted)
{
if (ctx.TryDequeue(out var work)) work();
else Thread.Sleep(1);
}
// Drain any tail continuations.
while (ctx.TryDequeue(out var work)) work();
task.GetAwaiter().GetResult();
done.SetResult(ctx);
}
catch (Exception ex) { done.SetException(ex); }
}) { IsBackground = true };
t.Start();
return done.Task;
}
/// <summary>Driver whose Initialize / Shutdown completions are caller-controlled via TCS.</summary>
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Tcs";
/// <summary>Initializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in TCS driver).</param>
/// <param name="ct">The cancellation token.</param>
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
/// <summary>Reinitializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in TCS driver).</param>
/// <param name="ct">The cancellation token.</param>
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
var t = new TaskCompletionSource();
t.SetResult();
return t;
}
}
/// <summary>SynchronizationContext that queues posts to a thread-safe work list and counts them.</summary>
private sealed class TrackingSynchronizationContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _queue = new();
public int PostCount;
public int SendCount;
/// <summary>Posts a callback to the work queue.</summary>
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostCount);
_queue.Enqueue(() => d(state));
}
/// <summary>Sends a callback synchronously.</summary>
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref SendCount);
d(state);
}
/// <summary>Attempts to dequeue a work item from the queue.</summary>
/// <param name="work">The dequeued work item if one was available.</param>
/// <returns>True if a work item was dequeued; otherwise false.</returns>
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
/// <summary>Resets the post and send counts.</summary>
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
}
}