b815446940
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
223 lines
11 KiB
C#
223 lines
11 KiB
C#
using System.Threading.Channels;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
|
|
|
/// <summary>
|
|
/// Driver-level infrastructure tests for finding Driver.Galaxy-007 (dispose-CTS gate).
|
|
/// Driver.Galaxy-011 (a non-zero GetMemoryFootprint estimate) was also covered here until
|
|
/// Gitea #525 deleted that member from IDriver as unconsumed surface.
|
|
/// </summary>
|
|
public sealed class GalaxyDriverInfrastructureTests
|
|
{
|
|
private static GalaxyDriverOptions Opts() => new(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
|
new GalaxyMxAccessOptions("InfraTest"),
|
|
new GalaxyRepositoryOptions(WatchDeployEvents: false),
|
|
new GalaxyReconnectOptions());
|
|
|
|
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
|
|
//
|
|
// Three tests lived here asserting the footprint tracked the subscription registry. The member
|
|
// was deleted from IDriver in Gitea #525 as unconsumed surface — nothing ever read the number —
|
|
// and they went with it. The registry sizes they leaned on are still covered directly by the
|
|
// subscribe/unsubscribe tests below.
|
|
|
|
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
|
|
|
|
/// <summary>Verifies that Dispose sets disposed flag and blocks further capability calls.</summary>
|
|
[Fact]
|
|
public async Task Dispose_SetsDisposedFlag_BlockingFurtherCapabilityCalls()
|
|
{
|
|
var sub = new NoOpSubscriber();
|
|
var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
|
|
driver.Dispose();
|
|
|
|
// Capability entry points all check ObjectDisposedException.ThrowIf — SubscribeAsync
|
|
// is representative and is guarded on line 1 of its body.
|
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
|
driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that DisposeAsync can be awaited without deadlock.</summary>
|
|
[Fact]
|
|
public async Task DisposeAsync_CanBeAwaitedWithoutDeadlock()
|
|
{
|
|
var sub = new NoOpSubscriber();
|
|
var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
|
|
// IAsyncDisposable.DisposeAsync must not block or throw.
|
|
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
|
|
}
|
|
|
|
// ===== Driver.Galaxy-013 regression: ReplayOnSessionLost gates the replay step =====
|
|
|
|
/// <summary>Verifies that ReplayOnSessionLost=false skips resubscription on reconnect.</summary>
|
|
[Fact]
|
|
public async Task ReplayOnSessionLost_False_SkipsResubscribeBulk()
|
|
{
|
|
// ReplayOnSessionLost was a dangling option — defined + documented but never
|
|
// read. After the fix, setting it to false makes the reconnect replay path
|
|
// skip SubscribeBulk (operator opts out of replay; the gateway's session-level
|
|
// ReplaySubscriptions handles state restoration).
|
|
var sub = new ReplayCountingSubscriber();
|
|
var opts = new GalaxyDriverOptions(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
|
new GalaxyMxAccessOptions("InfraTest"),
|
|
new GalaxyRepositoryOptions(WatchDeployEvents: false),
|
|
new GalaxyReconnectOptions(ReplayOnSessionLost: false));
|
|
|
|
using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub);
|
|
|
|
// Establish a subscription so the replay path has something to walk.
|
|
await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
|
|
sub.SubscribeCalls.ShouldBe(1);
|
|
|
|
// Invoke the replay path directly via the internal test seam — the supervisor's
|
|
// ReportTransportFailure spins it up async; for a deterministic assertion we
|
|
// call the helper that ReplayAsync is wired against.
|
|
await driver.InvokeReplayForTestAsync(CancellationToken.None);
|
|
|
|
sub.SubscribeCalls.ShouldBe(1,
|
|
"ReplayOnSessionLost=false must skip the re-SubscribeBulk fan-out on reconnect");
|
|
}
|
|
|
|
/// <summary>Verifies that ReplayOnSessionLost=true runs resubscription on reconnect.</summary>
|
|
[Fact]
|
|
public async Task ReplayOnSessionLost_True_RunsResubscribeBulk()
|
|
{
|
|
var sub = new ReplayCountingSubscriber();
|
|
var opts = new GalaxyDriverOptions(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
|
new GalaxyMxAccessOptions("InfraTest"),
|
|
new GalaxyRepositoryOptions(WatchDeployEvents: false),
|
|
new GalaxyReconnectOptions(ReplayOnSessionLost: true));
|
|
|
|
using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub);
|
|
|
|
await driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None);
|
|
sub.SubscribeCalls.ShouldBe(1);
|
|
|
|
await driver.InvokeReplayForTestAsync(CancellationToken.None);
|
|
|
|
sub.SubscribeCalls.ShouldBe(2,
|
|
"default ReplayOnSessionLost=true must re-issue SubscribeBulk after a transport drop");
|
|
}
|
|
|
|
/// <summary>Tracks subscription calls for replay testing.</summary>
|
|
private sealed class ReplayCountingSubscriber : IGalaxySubscriber
|
|
{
|
|
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
|
|
private int _nextHandle = 1;
|
|
/// <summary>Gets the count of subscription calls.</summary>
|
|
public int SubscribeCalls;
|
|
|
|
/// <summary>Subscribes to multiple tags and counts the call.</summary>
|
|
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
|
|
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
|
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref SubscribeCalls);
|
|
var results = fullReferences.Select(r => new SubscribeResult
|
|
{
|
|
TagAddress = r,
|
|
ItemHandle = Interlocked.Increment(ref _nextHandle),
|
|
WasSuccessful = true,
|
|
}).ToList();
|
|
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
|
}
|
|
|
|
/// <summary>Unsubscribes from multiple tags.</summary>
|
|
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <summary>Streams subscription events.</summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
|
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
|
}
|
|
|
|
// ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply =====
|
|
|
|
/// <summary>Verifies that ReinitializeAsync rejects non-equivalent config changes.</summary>
|
|
[Fact]
|
|
public async Task ReinitializeAsync_RejectsNonEquivalentConfigChange()
|
|
{
|
|
// ReinitializeAsync was previously a silent no-op that ignored driverConfigJson.
|
|
// After the fix it either applies an equivalent config (no-op) or throws
|
|
// NotSupportedException so a config change isn't silently dropped.
|
|
var sub = new NoOpSubscriber();
|
|
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
|
|
|
|
const string newConfig = "{\"Gateway\":{\"Endpoint\":\"https://other.test:5001\",\"ApiKeySecretRef\":\"dev:other\"}}";
|
|
|
|
// The driver must NOT pretend the change was applied — either no-op equivalence
|
|
// or an explicit rejection is acceptable. Silently dropping the new config
|
|
// (the previous behaviour) is not.
|
|
await Should.ThrowAsync<NotSupportedException>(async () =>
|
|
await driver.ReinitializeAsync(newConfig, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that ReinitializeAsync accepts equivalent config.</summary>
|
|
[Fact]
|
|
public async Task ReinitializeAsync_AcceptsEquivalentConfig()
|
|
{
|
|
var sub = new NoOpSubscriber();
|
|
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
|
|
|
|
// An empty / null-equivalent config reapply (no field changes) must not throw —
|
|
// it's a legitimate "refresh health" path. Pass a JSON object that round-trips
|
|
// to the driver's current options.
|
|
var json = "{\"Gateway\":{\"Endpoint\":\"https://mxgw.test:5001\",\"ApiKeySecretRef\":\"key\"}," +
|
|
"\"MxAccess\":{\"ClientName\":\"InfraTest\"}," +
|
|
"\"Repository\":{\"WatchDeployEvents\":false}," +
|
|
"\"Reconnect\":{}}";
|
|
await Should.NotThrowAsync(async () =>
|
|
await driver.ReinitializeAsync(json, CancellationToken.None));
|
|
}
|
|
|
|
// ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls =====
|
|
|
|
/// <summary>No-op subscriber that returns empty results for testing.</summary>
|
|
private sealed class NoOpSubscriber : IGalaxySubscriber
|
|
{
|
|
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
|
|
|
|
/// <summary>Subscribes to multiple tags (no-op).</summary>
|
|
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
|
|
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
|
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
|
{
|
|
var results = fullReferences.Select((r, i) => new SubscribeResult
|
|
{
|
|
TagAddress = r,
|
|
ItemHandle = i + 1,
|
|
WasSuccessful = true,
|
|
}).ToList();
|
|
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
|
}
|
|
|
|
/// <summary>Unsubscribes from multiple tags (no-op).</summary>
|
|
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <summary>Streams subscription events (no-op).</summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
|
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
|
}
|
|
}
|