Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
T
Joseph Doherty b815446940 refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)
Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. A UDT redefined on a live controller keeps its stale shape until the
  driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
  Agent restart. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a
footprint of 0 before the flush and 0 after with no discovery in between, so it
would have passed against a flush that did nothing at all.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 09:07:11 -04:00

286 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>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);
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); }
}
}