From 4fe0af36933a55d3943d723e69ad5d98528f8351 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:26:34 -0400 Subject: [PATCH] feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9) MTConnectDriver implements IDriver only: connection-free ctor, initialize (deadline-bounded /probe + /current, caching the device model, the agent instanceId, and a primed observation index), reinitialize, shutdown, health, footprint, and cache flush. Decisions worth knowing: - The driverConfigJson argument is HONOURED, not ignored. DriverInstanceActor delivers a config change only via ReinitializeAsync(json) on the live instance, so a driver reading only its ctor options would turn every MTConnect config edit into a silent no-op that still sealed the deployment green. ParseOptions lives on the driver; Task 15's factory must delegate to it rather than deserialize a second DTO. - /probe ok but priming /current failing is Faulted, not Healthy-with-no-data: the index IS the read surface and the /sample cursor comes from /current. - ReinitializeAsync rebuilds the client whenever ANY option the client reads at construction changed - AgentUri, DeviceName, RequestTimeoutMs, HeartbeatMs, SampleIntervalMs, SampleCount - not just the first two. The timing knobs are baked into the deadlines, the watchdog window, and the /sample query string. - An unparseable config faults a cold start but leaves a RUNNING driver serving its previous valid configuration (the deployment fails instead). - IMTConnectAgentClient now extends IDisposable rather than the driver doing `as IDisposable`, which would silently no-op against a non-disposable fake and leak an HttpClient + socket handler per re-deploy. Sync, not async: every resource behind the seam is sync-disposable, and the async unwind belongs to the SampleAsync enumerator the pump owns. - Flush drops the probe/browse cache and keeps the index; every model consumer goes through GetOrFetchProbeModelAsync so a flush cannot wedge browse. Tests: CannedAgentClient (shared, deterministic - channel-backed scripted /sample with no timers, call counts, disposal tracking, failure injection) + 27 lifecycle tests. 308/308 in the project. --- .../IMTConnectAgentClient.cs | 25 +- .../MTConnectDriver.cs | 729 ++++++++++++++++++ .../CannedAgentClient.cs | 202 +++++ .../MTConnectDriverLifecycleTests.cs | 548 +++++++++++++ 4 files changed, 1503 insertions(+), 1 deletion(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs index 86b9c98d..1d41561e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// canned probe/current/sample XML, with no sockets involved. /// /// +/// /// The production implementation, , carries no backend NuGet /// at all: it is an over the Agent's three request paths, feeding /// hand-rolled System.Xml.Linq parsers (MTConnectProbeParser / @@ -19,8 +20,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// needs to implement these three instance members; is a static /// protocol rule that lives here, rather than on the implementation, so a consumer written /// against this seam never has to reference the concrete client. +/// +/// +/// Why the seam is rather than +/// . The driver obtains its client through a +/// Func<MTConnectDriverOptions, IMTConnectAgentClient> and must release it on +/// ShutdownAsync and on every endpoint-changing ReinitializeAsync. Declaring +/// disposal here — instead of the driver doing as IDisposable + a null-conditional +/// call — is deliberate: that idiom silently no-ops against any future non-disposable +/// implementation, and the thing it would leak is an and its socket +/// handler, once per re-deploy, for the life of the process. +/// +/// +/// Synchronous disposal is the honest shape because every resource behind this seam is +/// synchronously disposable — two s and a +/// . An here would be a +/// synchronous method wearing a ValueTask, and it would oblige every canned-XML fake +/// to grow one too. The async unwind that a streaming client does need is already +/// covered elsewhere and is not this method's job: the enumerator +/// is itself and is owned by the pump that enumerates it, +/// while on the client converts any read still in flight +/// into an ordinary . +/// /// -public interface IMTConnectAgentClient +public interface IMTConnectAgentClient : IDisposable { /// /// Issues an Agent /probe request and returns the parsed device hierarchy. Called diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs new file mode 100644 index 00000000..abe20dd0 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -0,0 +1,729 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// MTConnect Agent implementation of — the lifecycle half. Everything the +/// driver serves sits behind one , so the whole class is +/// exercised against canned XML with no socket. +/// +/// +/// +/// The constructor opens nothing and builds nothing. It does not even call +/// agentClientFactory — the Wave-0 universal discovery browser constructs a throwaway +/// instance purely to ask whether the driver can browse, and a constructor that materialized +/// a client would open a connection pool per browse probe against a possibly-unreachable +/// Agent. Every connection is owned by . +/// +/// +/// The driverConfigJson argument is honoured, not decorative. The runtime hands +/// a config change to a live instance — DriverInstanceActor's ApplyDelta +/// calls with the new document and never rebuilds the object +/// — so a driver that read only its constructor options would turn every MTConnect config +/// edit into a silent no-op that still seals the deployment green. This class therefore +/// re-parses the document () whenever it carries a real body, +/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that +/// ignore the argument. A blank / {} / [] document means "no config supplied" +/// and keeps the constructor options, which is what unit tests and the factory path rely on. +/// +/// +/// Failure is never dressed as success (#485). A failed initialize disposes whatever +/// client it built, drops the probe cache and the observation index, publishes +/// with the error text, and rethrows — the runtime's +/// retry/backoff loop is the recovery path. In particular a /probe that succeeds while +/// the priming /current fails is Faulted, not "Healthy with no data": the index IS the +/// read surface, so serving that state would render every tag +/// BadWaitingForInitialData indefinitely behind a green driver, and the +/// /sample cursor (Task 11) only exists because /current reported a +/// nextSequence. +/// +/// +/// Scope. This type currently implements only. +/// IReadable (Task 10), ISubscribable (Task 11), ITagDiscovery +/// (Task 12) and IHostConnectivityProbe/IRediscoverable (Task 13) are added on +/// top of the state this class already caches — the probe model, the Agent +/// instanceId, and the observation index. +/// +/// +public sealed class MTConnectDriver : IDriver +{ + /// + /// Coarse per-entry byte weights behind . The contract asks + /// for an approximate driver-attributable footprint that Core polls every 30 s to + /// decide whether to ask for a flush, so an order-of-magnitude estimate from the two things + /// that actually scale with a plant (declared data items, live observations) is the right + /// precision. Measuring real retained size would need a heap walk per poll. + /// + private const int ProbeModelBytesPerDataItem = 512; + + /// Estimated retained bytes per live dataItemId → snapshot entry. + private const int IndexBytesPerEntry = 256; + + /// Estimated retained bytes per authored . + private const int TagBytesPerEntry = 256; + + /// + /// Config-JSON reader options, mirroring the sibling driver factories. Note there is + /// deliberately no JsonStringEnumConverter: enum-carrying DTO fields stay + /// string? and go through , which is the project-wide + /// guard against the numerically-serialized-enum trap that faults a driver at deploy time. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private readonly string _driverInstanceId; + private readonly ILogger _logger; + private readonly Func _agentClientFactory; + + /// + /// Serializes the three lifecycle entry points against each other. Initialize / Reinitialize + /// / Shutdown all swap the client and the served caches; overlapping them would let a + /// shutdown dispose a client an in-flight initialize is about to publish. + /// + private readonly SemaphoreSlim _lifecycle = new(1, 1); + + private MTConnectDriverOptions _options; + private IMTConnectAgentClient? _client; + private MTConnectObservationIndex _index; + private MTConnectProbeModel? _probeModel; + private int _probeModelDataItemCount; + private long? _agentInstanceId; + private DriverHealth _health = new(DriverState.Unknown, null, null); + + /// Creates a driver instance. Opens no connection and builds no agent client. + /// + /// The typed configuration this instance starts from. A config document supplied to + /// / supersedes it. + /// + /// Stable logical id of this driver instance, from the config DB. + /// + /// Builds the Agent client from the effective options. Defaults to the production + /// ; tests inject a canned-XML fake. Called from + /// , never from this constructor. + /// + /// Optional logger; defaults to the null logger. + public MTConnectDriver( + MTConnectDriverOptions options, + string driverInstanceId, + Func? agentClientFactory = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + + _options = options; + _driverInstanceId = driverInstanceId; + _logger = logger ?? NullLogger.Instance; + _agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger)); + + // Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot + // without a null check: an un-primed index answers BadWaitingForInitialData for an authored + // tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs. + _index = new MTConnectObservationIndex(options.Tags); + } + + /// + public string DriverInstanceId => _driverInstanceId; + + /// + public string DriverType => "MTConnect"; + + /// + /// The live dataItemId → snapshot map. Consumed by IReadable (Task 10) and the + /// /sample pump (Task 11); exposed to tests as the observable proof that + /// actually primed it. + /// + internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index); + + /// + /// The cached /probe device model, or null when it has not been fetched or was + /// dropped by . Prefer + /// , which cannot observe the flushed hole. + /// + internal MTConnectProbeModel? CachedProbeModel => _probeModel; + + /// + /// The Agent's instanceId as of the last successful /current. Task 13 watches + /// this for change to raise rediscovery (an Agent restart invalidates every cached id). + /// + internal long? AgentInstanceId => _agentInstanceId; + + /// The options currently in force — the constructor's, or the last document applied. + internal MTConnectDriverOptions EffectiveOptions => _options; + + /// + /// + /// Builds the Agent client from the effective options, runs one deadline-bounded + /// /probe (caching the device model and its data-item count), then one + /// deadline-bounded /current to prime the observation index and record the Agent's + /// instanceId. Publishes on success; on any failure + /// disposes the client, clears the caches, publishes with + /// the error text, and rethrows. + /// + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // A re-Initialize on a live instance must not orphan the previous client. + await TeardownCoreAsync().ConfigureAwait(false); + + MTConnectDriverOptions options; + try + { + options = ResolveOptions(driverConfigJson); + } + catch (Exception ex) + { + // A config document that will not parse is just as fatal to a cold start as an + // unreachable Agent, and it must reach GetHealth() the same way — otherwise the + // dashboard keeps showing whatever state the driver was in before. + WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)); + _logger.LogError( + ex, + "MTConnect driver {DriverInstanceId} could not read its driver config document; the driver is Faulted.", + _driverInstanceId); + + throw; + } + + await StartCoreAsync(options, existingClient: null, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycle.Release(); + } + } + + /// + /// + /// + /// Stops the /sample stream, then takes one of two paths depending on whether the + /// new document changes anything the reads at + /// construction: + /// + /// + /// Endpoint/transport changed (AgentUri, DeviceName, + /// RequestTimeoutMs, HeartbeatMs, SampleIntervalMs, + /// SampleCount) ⇒ full teardown and rebuild. The plan names only the first two, + /// but the other four are baked into the client's deadlines, its watchdog window, and its + /// /sample query string, so keeping the old client because the URI happened not to + /// change would silently discard the operator's edit — the same class of defect as + /// ignoring the config document altogether. + /// + /// + /// Otherwise ("config-only": authored tags, probe cadence, reconnect backoff) ⇒ + /// the live client and its connection pool are kept, and everything derived from config + /// is rebuilt: the probe model is re-fetched, the observation index is recreated against + /// the new tag types, and one /current re-primes it. Nothing config-derived + /// survives, so a retyped tag cannot keep coercing to its old type. + /// + /// + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + MTConnectDriverOptions incoming; + try + { + incoming = ResolveOptions(driverConfigJson); + } + catch (Exception ex) + { + // Deliberately NOT Faulted, and deliberately before any teardown: the running + // driver is still serving its previous, valid configuration. Rejecting the edit + // fails the deployment (DriverInstanceActor turns this into ApplyResult(false)); + // downing a working driver over an unreadable document would be a far larger blast + // radius than the change that was refused. + _logger.LogError( + ex, + "MTConnect driver {DriverInstanceId} rejected a new driver config document and kept running its previous configuration.", + _driverInstanceId); + + throw; + } + + var existing = _client; + + await StopSampleStreamAsync().ConfigureAwait(false); + + if (existing is null || RequiresClientRebuild(_options, incoming)) + { + await TeardownCoreAsync().ConfigureAwait(false); + await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false); + + return; + } + + await StartCoreAsync(incoming, existing, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycle.Release(); + } + } + + /// + /// + /// Stops the /sample stream, disposes the Agent client, and drops the probe cache and + /// the observation index — values sourced from a torn-down connection must never keep + /// reading Good. LastSuccessfulRead is retained because it is diagnostic ("when did + /// this driver last hear from the Agent"), not served data. Idempotent. + /// + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var lastRead = ReadHealth().LastSuccessfulRead; + + await StopSampleStreamAsync().ConfigureAwait(false); + await TeardownCoreAsync().ConfigureAwait(false); + + WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null)); + } + finally + { + _lifecycle.Release(); + } + } + + /// + public DriverHealth GetHealth() => ReadHealth(); + + /// + /// + /// Sums the three caches that scale with plant size: the /probe device model (by + /// declared data item), the live observation index (by indexed data item), and the authored + /// tag table. See for why the weights are coarse + /// constants. + /// + public long GetMemoryFootprint() => + ((long)_probeModelDataItemCount * ProbeModelBytesPerDataItem) + + ((long)ObservationIndex.Count * IndexBytesPerEntry) + + ((long)_options.Tags.Count * TagBytesPerEntry); + + /// + /// + /// Drops the /probe device model — the browse cache, which is re-derivable from the + /// Agent at any time — and keeps the observation index, which is required for correctness + /// (it is the read surface, and re-deriving it would mean a full re-prime the caller did not + /// ask for). Flushing cannot wedge the driver: every consumer of the model goes through + /// , which re-fetches and re-caches on a miss. + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) + { + var dropped = _probeModelDataItemCount; + + _probeModel = null; + _probeModelDataItemCount = 0; + + if (dropped > 0) + { + _logger.LogInformation( + "MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.", + _driverInstanceId, dropped); + } + + return Task.CompletedTask; + } + + /// + /// The /probe device model: the cached copy when warm, otherwise a fresh + /// deadline-bounded /probe that re-populates the cache. This is the accessor every + /// model consumer (Task 12's DiscoverAsync) must use, so that + /// can never leave browse permanently disabled. + /// + /// Cancellation token. + /// The Agent's device model. + /// The driver is not initialized. + internal async Task GetOrFetchProbeModelAsync(CancellationToken cancellationToken) + { + var cached = _probeModel; + if (cached is not null) + { + return cached; + } + + var client = _client + ?? throw new InvalidOperationException( + $"MTConnect driver '{_driverInstanceId}' has no agent client; the /probe device model cannot be fetched before InitializeAsync succeeds."); + + var model = await BoundedAsync(client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken) + .ConfigureAwait(false); + + CacheProbeModel(model); + + return model; + } + + /// + /// Builds driver options from a DriverConfig JSON document. + /// + /// + /// Lives here rather than on the factory because the driver itself needs it: the runtime + /// delivers config changes as JSON to a live instance (see the class remarks). Task 15's + /// MTConnectDriverFactoryExtensions.CreateInstance should delegate to this method + /// rather than deserializing a second DTO — two parsers for one document drift. + /// + /// Driver instance id, used only in error text. + /// The driver's DriverConfig JSON. + /// The parsed options. + /// The document is unusable or omits agentUri. + internal static MTConnectDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + MTConnectDriverConfigDto? dto; + try + { + dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"MTConnect driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex); + } + + if (dto is null) + { + throw new InvalidOperationException( + $"MTConnect driver config for '{driverInstanceId}' deserialised to null"); + } + + if (string.IsNullOrWhiteSpace(dto.AgentUri)) + { + throw new InvalidOperationException( + $"MTConnect driver config for '{driverInstanceId}' missing required AgentUri"); + } + + return new MTConnectDriverOptions + { + AgentUri = dto.AgentUri.Trim(), + DeviceName = string.IsNullOrWhiteSpace(dto.DeviceName) ? null : dto.DeviceName.Trim(), + RequestTimeoutMs = dto.RequestTimeoutMs ?? 5_000, + SampleIntervalMs = dto.SampleIntervalMs ?? 1_000, + SampleCount = dto.SampleCount ?? 1_000, + HeartbeatMs = dto.HeartbeatMs ?? 10_000, + Tags = dto.Tags is { Count: > 0 } + ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] + : [], + Probe = new MTConnectProbeOptions + { + Enabled = dto.Probe?.Enabled ?? true, + Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), + Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), + }, + Reconnect = new MTConnectReconnectOptions + { + MinBackoffMs = dto.Reconnect?.MinBackoffMs ?? 0, + MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000, + BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0, + }, + }; + } + + // ---- lifecycle internals (all called with _lifecycle held) ---- + + /// + /// Brings the driver up against , reusing + /// when the caller determined the transport is unchanged. + /// Nothing is published until BOTH agent calls have succeeded, so a failure can never leave a + /// half-initialized instance behind. + /// + private async Task StartCoreAsync( + MTConnectDriverOptions options, IMTConnectAgentClient? existingClient, CancellationToken ct) + { + var lastRead = ReadHealth().LastSuccessfulRead; + WriteHealth(new DriverHealth(DriverState.Initializing, lastRead, null)); + + IMTConnectAgentClient? built = null; + try + { + // arch-review 01/S-6: a 0 authored here must never come to mean "wait forever". Checked + // in the driver, not only in the production client's constructor, so the guard holds for + // any client implementation behind the seam. + RequirePositive(options.RequestTimeoutMs, nameof(MTConnectDriverOptions.RequestTimeoutMs)); + RequirePositive(options.HeartbeatMs, nameof(MTConnectDriverOptions.HeartbeatMs)); + RequirePositive(options.SampleIntervalMs, nameof(MTConnectDriverOptions.SampleIntervalMs)); + RequirePositive(options.SampleCount, nameof(MTConnectDriverOptions.SampleCount)); + + var client = existingClient; + if (client is null) + { + built = _agentClientFactory(options) + ?? throw new InvalidOperationException( + $"The MTConnect agent-client factory for driver '{_driverInstanceId}' returned null."); + client = built; + } + + var probe = await BoundedAsync(client.ProbeAsync, "/probe", options.RequestTimeoutMs, ct) + .ConfigureAwait(false); + var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, ct) + .ConfigureAwait(false); + + // ---- commit point: everything below is non-throwing bookkeeping ---- + _options = options; + _client = client; + built = null; + + CacheProbeModel(probe); + _agentInstanceId = current.InstanceId; + + var index = new MTConnectObservationIndex(options.Tags); + index.Apply(current); + Volatile.Write(ref _index, index); + + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + + _logger.LogInformation( + "MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.", + _driverInstanceId, + options.AgentUri, + options.DeviceName is null ? string.Empty : $"/{options.DeviceName}", + probe.Devices.Count, + index.Count, + current.InstanceId); + } + catch (Exception ex) + { + // A client this attempt built is unreachable from anywhere else; one the caller handed in + // is disposed by the teardown below. Either way nothing survives a failed start. + SafeDispose(built); + await TeardownCoreAsync().ConfigureAwait(false); + + WriteHealth(new DriverHealth(DriverState.Faulted, lastRead, ex.Message)); + + _logger.LogError( + ex, + "MTConnect driver {DriverInstanceId} failed to initialize against {AgentUri}; the driver is Faulted and serves no values.", + _driverInstanceId, options.AgentUri); + + throw; + } + } + + /// + /// Releases the agent client and drops every piece of served state. Never throws — it runs + /// on the failure path of , where a second exception would mask + /// the real one. + /// + private Task TeardownCoreAsync() + { + var client = _client; + _client = null; + SafeDispose(client); + + _probeModel = null; + _probeModelDataItemCount = 0; + _agentInstanceId = null; + + // A fresh index rather than Clear(): the tag table it coerces against belongs to the options + // that are being torn down. + Volatile.Write(ref _index, new MTConnectObservationIndex(_options.Tags)); + + return Task.CompletedTask; + } + + /// + /// Stops the shared /sample long-poll stream. A no-op until Task 11 introduces the + /// pump; it exists now because both and + /// must stop the stream before the client they are about + /// to dispose goes away, and that ordering is easy to lose when the pump is bolted on later. + /// + private static Task StopSampleStreamAsync() => Task.CompletedTask; + + /// The document's options when it carries a body; otherwise the ones already in force. + private MTConnectDriverOptions ResolveOptions(string driverConfigJson) => + HasConfigBody(driverConfigJson) ? ParseOptions(_driverInstanceId, driverConfigJson) : _options; + + /// + /// True when carries a real config body. The + /// bootstrapper always passes a populated document; tests and probe-only callers pass + /// "{}", "[]" or blank, which keep the constructor-supplied options. + /// + private static bool HasConfigBody(string? driverConfigJson) + { + if (string.IsNullOrWhiteSpace(driverConfigJson)) + { + return false; + } + + var trimmed = driverConfigJson.Trim(); + + return trimmed is not "{}" and not "[]"; + } + + /// + /// Whether the new options change anything reads at + /// construction, and therefore cannot be applied to a live client. See + /// for why this is wider than the plan's AgentUri/DeviceName. + /// + private static bool RequiresClientRebuild(MTConnectDriverOptions current, MTConnectDriverOptions incoming) => + !string.Equals(current.AgentUri, incoming.AgentUri, StringComparison.Ordinal) + || !string.Equals(current.DeviceName, incoming.DeviceName, StringComparison.Ordinal) + || current.RequestTimeoutMs != incoming.RequestTimeoutMs + || current.HeartbeatMs != incoming.HeartbeatMs + || current.SampleIntervalMs != incoming.SampleIntervalMs + || current.SampleCount != incoming.SampleCount; + + private void CacheProbeModel(MTConnectProbeModel model) + { + _probeModel = model; + + // Counted once here rather than re-walked on every 30 s footprint poll. + _probeModelDataItemCount = model.Devices.Sum(d => d.AllDataItems().Count()); + } + + /// + /// Runs one unary agent call under a deadline linked to the caller's token. Belt-and-braces + /// over the production client's own per-call deadline: the seam does not oblige an + /// implementation to bound itself, and an unbounded initialize would wedge the driver-host + /// actor (arch-review R2-01). + /// + private async Task BoundedAsync( + Func> call, string request, int timeoutMs, CancellationToken ct) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs)); + + try + { + return await call(deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // A bare "A task was canceled" names neither the Agent nor the request that stalled. + throw new TimeoutException( + $"MTConnect {request} request for driver '{_driverInstanceId}' against '{_options.AgentUri}' did not complete within {timeoutMs} ms."); + } + } + + private static void SafeDispose(IMTConnectAgentClient? client) + { + if (client is null) + { + return; + } + + try + { + client.Dispose(); + } + catch (Exception) + { + // Teardown runs on failure paths; a disposal fault must not replace the original error. + } + } + + private static void RequirePositive(int value, string optionName) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(MTConnectDriverOptions), + value, + $"{optionName} must be positive; a non-positive value would either un-bound a deadline or ask the Agent for nothing."); + } + } + + /// Barrier-protected read of the cross-thread health snapshot. + private DriverHealth ReadHealth() => Volatile.Read(ref _health); + + /// Barrier-protected publish of a new health snapshot. + private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value); + + private static MTConnectTagDefinition BuildTag(MTConnectTagDto dto, string driverInstanceId) + { + if (string.IsNullOrWhiteSpace(dto.FullName)) + { + throw new InvalidOperationException( + $"MTConnect driver config for '{driverInstanceId}' has a tag with no FullName (the Agent DataItem id it binds)."); + } + + return new MTConnectTagDefinition( + dto.FullName.Trim(), + // Absent means "no declared type": String is the one coercion that cannot fail, and it + // carries the Agent's text through untouched. + dto.DriverDataType is null + ? DriverDataType.String + : ParseEnum(dto.DriverDataType, dto.FullName, driverInstanceId, nameof(MTConnectTagDefinition.DriverDataType)), + dto.IsArray ?? false, + dto.ArrayDim ?? 0, + dto.MtCategory, + dto.MtType, + dto.MtSubType, + dto.Units); + } + + /// + /// Parses an enum authored as a name. Config surfaces serialize enums as names + /// project-wide; a numerically-serialized enum is the known trap that faults a driver at + /// deploy, so this reports the offending field rather than silently taking member 0. + /// + private static T ParseEnum(string raw, string tagName, string driverInstanceId, string field) + where T : struct, Enum => + Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed) + ? parsed + : throw new InvalidOperationException(string.Create( + CultureInfo.InvariantCulture, + $"MTConnect driver config for '{driverInstanceId}' tag '{tagName}' has an unrecognised {field} '{raw}'. Expected one of: {string.Join(", ", Enum.GetNames())}.")); + + // ---- config DTOs ---- + + /// + /// Nullable-init mirror of the driver's DriverConfig JSON. Every property is nullable + /// so an omitted key means "use the default" rather than "reset to zero". + /// + private sealed class MTConnectDriverConfigDto + { + public string? AgentUri { get; init; } + public string? DeviceName { get; init; } + public int? RequestTimeoutMs { get; init; } + public int? SampleIntervalMs { get; init; } + public int? SampleCount { get; init; } + public int? HeartbeatMs { get; init; } + public List? Tags { get; init; } + public MTConnectProbeDto? Probe { get; init; } + public MTConnectReconnectDto? Reconnect { get; init; } + } + + /// One authored tag. DriverDataType stays a string — see . + private sealed class MTConnectTagDto + { + public string? FullName { get; init; } + public string? DriverDataType { get; init; } + public bool? IsArray { get; init; } + public int? ArrayDim { get; init; } + public string? MtCategory { get; init; } + public string? MtType { get; init; } + public string? MtSubType { get; init; } + public string? Units { get; init; } + } + + /// Background connectivity-probe knobs (Task 13 consumes them). + private sealed class MTConnectProbeDto + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + } + + /// Reconnect-backoff knobs (Task 11's pump consumes them). + private sealed class MTConnectReconnectDto + { + public int? MinBackoffMs { get; init; } + public int? MaxBackoffMs { get; init; } + public double? BackoffMultiplier { get; init; } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs new file mode 100644 index 00000000..85acca43 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -0,0 +1,202 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// The shared test double: an Agent that serves the canned +/// Fixtures/probe.xml + Fixtures/current.xml documents, counts every call, records +/// its own disposal, and lets a test drive the /sample chunk sequence by hand. +/// +/// +/// +/// Deterministic by construction — no timers, no sleeps, no polling. The +/// /sample leg reads from an unbounded that only a test +/// fills, and does not return until the driver has actually consumed +/// the chunk it wrote (each chunk carries its own completion source, signalled after the +/// enumerator's yield return resumes). A subscription test can therefore say "one +/// chunk has now been fully processed" as a fact rather than as a timing hope. +/// +/// +/// It honours the seam's stream-end contract (see +/// ): cancelling the token is the only way the +/// enumeration ends without throwing. Closing the scripted stream via +/// raises , exactly as the +/// production client does when an Agent drops the connection — so a pump written against the +/// fake cannot silently pass while mishandling the real one. +/// +/// +/// Disposal is observable () because "did the driver +/// actually release the client?" is a behaviour, not an implementation detail: a +/// ReinitializeAsync that re-points the Agent but leaks the old client keeps a live +/// connection pool per re-deploy, and nothing else in the test surface would notice. +/// +/// +internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable +{ + private readonly Channel _chunks = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _disposeCts = new(); + + private int _probeCallCount; + private int _currentCallCount; + private int _sampleCallCount; + private int _disposeCount; + + private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current) + { + Probe = probe; + Current = current; + } + + /// + /// Builds a client serving the repo's canned fixtures — Fixtures/probe.xml for + /// /probe and Fixtures/current.xml for /current. + /// + /// Probe-document fixture path, relative to the test binaries. + /// Streams-document fixture path, relative to the test binaries. + public static CannedAgentClient FromFixtures( + string probeFixture = "Fixtures/probe.xml", + string currentFixture = "Fixtures/current.xml") => + new( + MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)), + MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture))); + + /// Parses a streams fixture into a chunk a test can script onto the sample stream. + /// Streams-document fixture path, relative to the test binaries. + public static MTConnectStreamsResult Chunk(string fixture) => + MTConnectStreamsParser.Parse(File.ReadAllText(fixture)); + + /// The document /probe answers with. Settable so a test can re-shape the model. + public MTConnectProbeModel Probe { get; set; } + + /// + /// The document /current answers with. Settable so a re-baseline (or an agent-restart + /// instanceId change) can be scripted between calls. + /// + public MTConnectStreamsResult Current { get; set; } + + /// When set, /probe throws this instead of answering. + public Exception? ProbeFailure { get; set; } + + /// When set, /current throws this instead of answering. + public Exception? CurrentFailure { get; set; } + + /// Number of /probe requests issued against this client. + public int ProbeCallCount => Volatile.Read(ref _probeCallCount); + + /// Number of /current requests issued against this client. + public int CurrentCallCount => Volatile.Read(ref _currentCallCount); + + /// Number of /sample enumerations started against this client. + public int SampleCallCount => Volatile.Read(ref _sampleCallCount); + + /// Number of calls (not clamped — a double-dispose is visible). + public int DisposeCount => Volatile.Read(ref _disposeCount); + + /// Whether the client has been disposed at least once. + public bool IsDisposed => DisposeCount > 0; + + /// The from sequence the most recent /sample enumeration was opened at. + public long? LastSampleFrom { get; private set; } + + /// + public Task ProbeAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _probeCallCount); + + return ProbeFailure is null ? Task.FromResult(Probe) : Task.FromException(ProbeFailure); + } + + /// + public Task CurrentAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _currentCallCount); + + return CurrentFailure is null + ? Task.FromResult(Current) + : Task.FromException(CurrentFailure); + } + + /// + public async IAsyncEnumerable SampleAsync( + long from, [EnumeratorCancellation] CancellationToken ct) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + Interlocked.Increment(ref _sampleCallCount); + LastSampleFrom = from; + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token); + var delivered = 0L; + + while (true) + { + ScriptedChunk scripted; + try + { + scripted = await _chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + // Mirrors the production client: a stream that stops for any reason other than the + // caller cancelling is an exception, never a quiet end of enumeration. + throw new MTConnectStreamEndedException( + MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered); + } + + delivered++; + + yield return scripted.Result; + + scripted.Consumed.TrySetResult(); + } + } + + /// + /// Queues a chunk onto the scripted /sample stream and waits until the consumer has + /// processed it. This is the deterministic replacement for "wait a bit and hope the pump + /// ran". + /// + /// The chunk the Agent should send next. + /// A task completing once the enumerating consumer has moved past . + public Task PumpAsync(MTConnectStreamsResult chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + var scripted = new ScriptedChunk( + chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + + if (!_chunks.Writer.TryWrite(scripted)) + { + throw new InvalidOperationException("The scripted /sample stream is already closed."); + } + + return scripted.Consumed.Task; + } + + /// + /// Closes the scripted /sample stream, which surfaces to the consumer as an + /// — the transient, reconnectable end. + /// + public void EndStream() => _chunks.Writer.TryComplete(); + + /// + public void Dispose() + { + Interlocked.Increment(ref _disposeCount); + + if (DisposeCount > 1) + { + return; + } + + _disposeCts.Cancel(); + _chunks.Writer.TryComplete(); + _disposeCts.Dispose(); + } + + private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed); +} 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 new file mode 100644 index 00000000..d08036b3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -0,0 +1,548 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 9 — 's lifecycle over the +/// seam: construction, initialize, reinitialize, shutdown, +/// health, footprint, and cache flush. Every test runs against +/// ; no socket is opened anywhere in this file. +/// +/// +/// The load-bearing assertions here are the ones about what must NOT happen: a +/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that +/// 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 +{ + private const string AgentUri = "http://fixture-agent:5000"; + + /// Every dataItemId the canned probe declares — the browse-cache footprint basis. + private const int FixtureDataItemCount = 7; + + private static MTConnectDriverOptions Opts( + string agentUri = AgentUri, + string? deviceName = null, + int requestTimeoutMs = 5000) => + new() + { + AgentUri = agentUri, + DeviceName = deviceName, + RequestTimeoutMs = requestTimeoutMs, + Tags = + [ + new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), + new MTConnectTagDefinition("dev1_execution", DriverDataType.String), + ], + }; + + /// A driver wired to one canned client, plus that client, for the common case. + private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( + MTConnectDriverOptions? options = null) + { + var client = CannedAgentClient.FromFixtures(); + + return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client); + } + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync() + { + var (driver, client) = NewDriver(); + await driver.InitializeAsync("{}", CancellationToken.None); + + return (driver, client); + } + + // ---- connection-free construction ---- + + [Fact] + public void Constructor_dials_nothing_and_does_not_even_build_a_client() + { + var factoryCalls = 0; + var client = CannedAgentClient.FromFixtures(); + + var driver = new MTConnectDriver(Opts(), "mt1", _ => + { + factoryCalls++; + + return client; + }); + + // The universal discovery browser constructs a throwaway instance purely to ask CanBrowse. + // If the ctor built (or worse, dialled) a client, every browse probe would open a connection + // pool against a possibly-unreachable Agent. + factoryCalls.ShouldBe(0); + client.ProbeCallCount.ShouldBe(0); + client.CurrentCallCount.ShouldBe(0); + client.SampleCallCount.ShouldBe(0); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + [Fact] + public void Identity_is_the_constructor_arguments() + { + var (driver, _) = NewDriver(); + + driver.DriverInstanceId.ShouldBe("mt1"); + driver.DriverType.ShouldBe("MTConnect"); + } + + // ---- initialize ---- + + [Fact] + public async Task Initialize_primes_index_and_reports_healthy() + { + var (driver, client) = NewDriver(); + + await driver.InitializeAsync("{}", CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + client.ProbeCallCount.ShouldBe(1); + client.CurrentCallCount.ShouldBe(1); + + // The /current snapshot is what makes the driver able to answer a read at all. + driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); + driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u); + } + + [Fact] + public async Task Initialize_records_the_agent_instance_id_and_the_probe_model() + { + var (driver, client) = NewDriver(); + + await driver.InitializeAsync("{}", CancellationToken.None); + + driver.AgentInstanceId.ShouldBe(client.Current.InstanceId); + driver.CachedProbeModel.ShouldNotBeNull(); + driver.CachedProbeModel!.Devices.Count.ShouldBe(1); + } + + [Fact] + public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries() + { + var (driver, _) = NewDriver(); + + var before = DateTime.UtcNow; + await driver.InitializeAsync("{}", CancellationToken.None); + var after = DateTime.UtcNow; + + var lastRead = driver.GetHealth().LastSuccessfulRead; + lastRead.ShouldNotBeNull(); + lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before); + lastRead.Value.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public async Task Initialize_faults_and_rethrows_when_probe_fails() + { + var (driver, client) = NewDriver(); + client.ProbeFailure = new HttpRequestException("agent unreachable"); + + var thrown = await Should.ThrowAsync( + () => driver.InitializeAsync("{}", CancellationToken.None)); + + thrown.Message.ShouldBe("agent unreachable"); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Faulted); + health.LastError.ShouldNotBeNull(); + health.LastError!.ShouldContain("agent unreachable"); + + // No half-initialized object: the client it built must not survive the failure. + client.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails() + { + // Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose + // priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy + // there would render every tag BadWaitingForInitialData forever while the dashboard shows a + // green driver — the #485 "a failure that looks like success" shape. + var (driver, client) = NewDriver(); + client.CurrentFailure = new InvalidDataException("current unparseable"); + + await Should.ThrowAsync( + () => driver.InitializeAsync("{}", CancellationToken.None)); + + client.ProbeCallCount.ShouldBe(1); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + driver.GetHealth().State.ShouldNotBe(DriverState.Healthy); + driver.CachedProbeModel.ShouldBeNull(); + driver.ObservationIndex.Count.ShouldBe(0); + client.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task Initialize_rejects_a_non_positive_request_timeout_before_dialling() + { + // arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait forever". + // Asserted through a FAKE client factory, so this proves the DRIVER validates rather than + // relying on the production client's own constructor guard. + var factoryCalls = 0; + var driver = new MTConnectDriver(Opts(requestTimeoutMs: 0), "mt1", _ => + { + factoryCalls++; + + return CannedAgentClient.FromFixtures(); + }); + + await Should.ThrowAsync( + () => driver.InitializeAsync("{}", CancellationToken.None)); + + factoryCalls.ShouldBe(0); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + // ---- the driverConfigJson argument ---- + + [Fact] + public async Task Initialize_honours_the_config_json_over_the_constructor_options() + { + // The runtime delivers a config change ONLY through this argument (DriverInstanceActor + // ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every + // MTConnect config change a silent no-op that still seals the deployment green. + MTConnectDriverOptions? seen = null; + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + seen = o; + + return CannedAgentClient.FromFixtures(); + }); + + await driver.InitializeAsync( + """{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""", + CancellationToken.None); + + seen.ShouldNotBeNull(); + seen!.AgentUri.ShouldBe("http://other-agent:5001"); + seen.DeviceName.ShouldBe("VMC-3Axis"); + } + + [Fact] + public async Task Initialize_with_an_empty_config_body_keeps_the_constructor_options() + { + MTConnectDriverOptions? seen = null; + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + seen = o; + + return CannedAgentClient.FromFixtures(); + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + + seen.ShouldNotBeNull(); + seen!.AgentUri.ShouldBe(AgentUri); + seen.Tags.Count.ShouldBe(2); + } + + [Fact] + public async Task Initialize_faults_on_a_config_body_with_no_agent_uri() + { + var (driver, _) = NewDriver(); + + await Should.ThrowAsync( + () => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None)); + + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + [Fact] + public async Task Initialize_parses_authored_tags_out_of_the_config_json() + { + MTConnectDriverOptions? seen = null; + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + seen = o; + + return CannedAgentClient.FromFixtures(); + }); + + await driver.InitializeAsync( + """ + {"agentUri":"http://a:5000", + "tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]} + """, + CancellationToken.None); + + seen.ShouldNotBeNull(); + seen!.Tags.Count.ShouldBe(1); + seen.Tags[0].FullName.ShouldBe("dev1_partcount"); + + // Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES. + seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32); + } + + // ---- reinitialize ---- + + [Fact] + public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client() + { + var built = new List(); + var driver = new MTConnectDriver(Opts(), "mt1", _ => + { + var c = CannedAgentClient.FromFixtures(); + built.Add(c); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + await driver.ReinitializeAsync( + $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""", + CancellationToken.None); + + // Config-only: the live connection pool survives, so no client churn... + built.Count.ShouldBe(1); + built[0].IsDisposed.ShouldBeFalse(); + + // ...but everything DERIVED from config is genuinely rebuilt against the new document. + built[0].ProbeCallCount.ShouldBe(2); + built[0].CurrentCallCount.ShouldBe(2); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client() + { + var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + var c = CannedAgentClient.FromFixtures(); + built.Add((o, c)); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + await driver.ReinitializeAsync( + """{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None); + + // A re-pointed driver that kept the old client would keep reading the OLD machine while the + // deployment reports success. + built.Count.ShouldBe(2); + built[0].Client.IsDisposed.ShouldBeTrue(); + built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000"); + built[1].Client.IsDisposed.ShouldBeFalse(); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client() + { + var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + var c = CannedAgentClient.FromFixtures(); + built.Add((o, c)); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + await driver.ReinitializeAsync( + $$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None); + + // DeviceName is baked into every request URI at client construction. + built.Count.ShouldBe(2); + built[0].Client.IsDisposed.ShouldBeTrue(); + built[1].Options.DeviceName.ShouldBe("VMC-3Axis"); + } + + [Fact] + public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client() + { + // RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client + // CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client + // because the URI happened not to change would silently discard the operator's edit. + var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>(); + var driver = new MTConnectDriver(Opts(), "mt1", o => + { + var c = CannedAgentClient.FromFixtures(); + built.Add((o, c)); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + await driver.ReinitializeAsync( + $$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None); + + built.Count.ShouldBe(2); + built[1].Options.HeartbeatMs.ShouldBe(2500); + built[0].Client.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task Reinitialize_before_initialize_behaves_as_initialize() + { + var (driver, client) = NewDriver(); + + await driver.ReinitializeAsync("{}", CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + client.ProbeCallCount.ShouldBe(1); + } + + [Fact] + public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy() + { + var built = new List(); + var driver = new MTConnectDriver(Opts(), "mt1", _ => + { + var c = CannedAgentClient.FromFixtures(); + built.Add(c); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + built[0].CurrentFailure = new HttpRequestException("agent went away"); + + // Config-only shape (only the tag list changes), so the failing leg is the re-prime against + // the client that is already live — the path a rebuild would otherwise mask. + await Should.ThrowAsync( + () => driver.ReinitializeAsync( + $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None)); + + built.Count.ShouldBe(1); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away"); + + // A failed refresh must not leave a live client nobody owns. + built[0].IsDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving() + { + // Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must + // not down a driver that is happily serving its previous, valid configuration. + var (driver, client) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None)); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri); + client.IsDisposed.ShouldBeFalse(); + driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); + } + + // ---- shutdown ---- + + [Fact] + public async Task Shutdown_disposes_the_client_and_drops_the_served_state() + { + var (driver, client) = await InitializedDriverAsync(); + var lastRead = driver.GetHealth().LastSuccessfulRead; + + await driver.ShutdownAsync(CancellationToken.None); + + client.DisposeCount.ShouldBe(1); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + + // Retained: it is diagnostic ("when did we last hear from the Agent"), not served data. + driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead); + + // Dropped: values from a torn-down connection must never keep reading Good. + driver.ObservationIndex.Count.ShouldBe(0); + driver.CachedProbeModel.ShouldBeNull(); + } + + [Fact] + public async Task Shutdown_is_idempotent() + { + var (driver, client) = await InitializedDriverAsync(); + + await driver.ShutdownAsync(CancellationToken.None); + await driver.ShutdownAsync(CancellationToken.None); + + client.DisposeCount.ShouldBe(1); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + [Fact] + public async Task Shutdown_before_initialize_is_a_no_op() + { + var (driver, client) = NewDriver(); + + await driver.ShutdownAsync(CancellationToken.None); + + client.DisposeCount.ShouldBe(0); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + // ---- footprint + flush ---- + + [Fact] + public async Task Memory_footprint_is_zero_before_initialize_and_positive_after() + { + var (driver, _) = NewDriver(); + + var before = driver.GetMemoryFootprint(); + await driver.InitializeAsync("{}", CancellationToken.None); + var after = driver.GetMemoryFootprint(); + + // Before init the only config-attributable state is the two authored tags. + before.ShouldBeLessThan(after); + after.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index() + { + var (driver, _) = await InitializedDriverAsync(); + var before = driver.GetMemoryFootprint(); + + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + driver.GetMemoryFootprint().ShouldBeLessThan(before); + 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); + } + + [Fact] + public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand() + { + // Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a + // cache-budget breach would permanently disable browse on that driver instance. + var (driver, client) = await InitializedDriverAsync(); + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + client.ProbeCallCount.ShouldBe(1); + + var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None); + + model.Devices.Count.ShouldBe(1); + client.ProbeCallCount.ShouldBe(2); + driver.CachedProbeModel.ShouldNotBeNull(); + } + + [Fact] + public async Task Probe_model_is_served_from_cache_while_it_is_warm() + { + var (driver, client) = await InitializedDriverAsync(); + + _ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None); + + client.ProbeCallCount.ShouldBe(1); + } + + [Fact] + public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty() + { + var (driver, _) = NewDriver(); + + await Should.ThrowAsync( + () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); + } +}