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