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 and /// . 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. /// /// /// The read path never writes the shared observation index — see /// . That index has exactly one writer, and Task 11's /sample /// pump is it. /// /// public sealed class MTConnectDriver : IDriver, IReadable { /// /// 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; /// /// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct /// from the index's BadNoCommunication, which means the Agent answered and said the /// machine has no value — the two failures need different operator responses. /// private const uint BadCommunicationError = 0x80050000u; /// /// There is no Agent client at all: the driver has not been initialized, its initialize /// faulted, or it has been shut down. Deliberately not one of the index's codes — none of /// them would say "this driver is not running", which is the only true statement available. /// private const uint BadNotConnected = 0x808A0000u; /// /// 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, }; /// /// Reader options for the emptiness probe in . Kept in step with /// so a document the real parse would accept is never judged /// malformed here (a stray trailing comma must not change which branch a config takes). /// private static readonly JsonDocumentOptions DocumentOptions = new() { CommentHandling = 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. /// /// /// /// ⚠️ NON-REENTRANT — never call a public lifecycle method from code that already /// holds this. has no owner tracking, so a re-entrant /// call does not fail: it deadlocks the driver permanently, with no exception and /// no log line to diagnose it from. /// /// /// This is aimed squarely at the /sample pump (Task 11). Its ring-buffer /// re-baseline ("on a sequence gap, re-/current and resume") is naturally written /// as "just call the existing re-init logic" — and /// / both take this /// semaphore, so from inside the pump loop that is a hang, not a re-prime. Re-baseline /// must instead call directly on the /// client the pump already holds (through , to keep the /// deadline) and Apply the result to . Any future /// shared step belongs in a private, non-locking helper that both the pump and the /// lock-holding lifecycle methods can call — never in the public entry points. /// /// /// The inverse direction is already safe: the lifecycle methods call /// while holding this, so that method — and whatever /// Task 11 fills it with — must never re-enter the lifecycle either. /// /// private readonly SemaphoreSlim _lifecycle = new(1, 1); private MTConnectDriverOptions _options; private MTConnectObservationIndex _index; private long? _agentInstanceId; private DriverHealth _health = new(DriverState.Unknown, null, null); /// /// The live agent client plus the /probe cache derived from it, held as one /// immutable snapshot behind a single reference and only ever replaced by a /// of a whole new instance — the same discipline /// uses, and for the same reason. /// /// /// /// Why one field instead of three. These three values are read outside /// — by , by /// (Task 12's browse), and by /// — while every write happens under it. As three /// separate fields they could be observed mid-update: a reader could see a dropped /// ProbeModel beside a stale non-zero data-item count and report a footprint for /// a cache that no longer exists, or pair one initialize's client with the previous /// one's model. Packing them makes every observation internally consistent by /// construction, with no lock on the read path. /// /// /// Why not simply take the lock in those readers. Both readers await the /// network, so they would hold the lifecycle lock across an HTTP round trip — a browse /// could stall a for a full RequestTimeoutMs — and /// every added lock site widens the non-reentrancy hazard documented on /// . It would also make the read path inconsistent with /// , which is deliberately lock-free. /// /// /// What this does NOT fix. A reader can still capture a session moments before a /// concurrent teardown disposes its client, and then call it — no lock-free scheme can /// prevent that, and holding the lock across the call is the trade rejected above. /// What it guarantees is that the failure is named: the resulting /// is caught at the seam and reported as the same /// "driver is not connected" error a caller already has to handle, rather than escaping /// raw out of a browse. /// /// private AgentSession? _session; /// 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 that backs the subscription surface: /// primed by and thereafter written only by the /sample /// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed /// it — and, in MTConnectReadTests, that leaves it alone. /// /// /// Read does not consume this. indexes its own /current /// into a per-call snapshot instead, so the pump keeps a single writer — see the failure /// analysis in 's remarks. /// 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 => Volatile.Read(ref _session)?.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 { // Parse BEFORE tearing down — see ResolveIncomingOptions. Initialize is reachable on a // live instance (DriverInstanceActor re-initialises in Connecting/Reconnecting), and // destroying a working client before discovering the new document is unreadable would // be the exact outcome ReinitializeAsync is written to avoid. var options = ResolveIncomingOptions(driverConfigJson); // A re-Initialize on a live instance must not orphan the previous client. await TeardownCoreAsync().ConfigureAwait(false); 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 { var incoming = ResolveIncomingOptions(driverConfigJson); var existing = Volatile.Read(ref _session)?.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)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * 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. /// /// The drop is a single of a whole new /// , so the model and its data-item count can never be seen /// out of step (which would skew ), and a flush that /// races a concurrent teardown or re-initialize is discarded rather than resurrecting /// the session it was reading. /// /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) { var session = Volatile.Read(ref _session); if (session?.ProbeModel is null) { return Task.CompletedTask; } var flushed = session with { ProbeModel = null, ProbeModelDataItemCount = 0 }; if (!ReferenceEquals(Interlocked.CompareExchange(ref _session, flushed, session), session)) { // Someone swapped the session while we were building the flushed copy — their state is // newer than ours, and publishing would undo it. Whatever they installed is either a // teardown (no cache to flush) or a fresh initialize (a cache the operator's budget // breach never saw), so dropping this flush is correct, not merely safe. return Task.CompletedTask; } _logger.LogInformation( "MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.", _driverInstanceId, session.ProbeModelDataItemCount); return Task.CompletedTask; } /// /// /// /// One /current per batch, whatever the batch size. MTConnect's /// /current answers for the whole device (or the whole Agent), so N references /// cost one deadline-bounded round-trip and are answered out of that one document. There /// is no per-reference request to make and none is made. /// /// /// The response is indexed into a per-call snapshot, NOT into the driver's shared /// observation index. That index is written by the /sample pump (Task 11) and /// is deliberately last-write-wins with no timestamp arbitration. A read that folded its /// /current into it would be a second writer racing the first, and the /// /current document is frequently the older of the two — the Agent /// composes it while the pump's newer delta is already in flight. Losing that race rolls /// a subscribed value backwards and leaves it there until the data item next /// changes, which for an EVENT such as Execution can be hours. Keeping the /// read path a pure function of (document, authored tags) costs one index construction /// per batch — negligible beside the HTTP round-trip that precedes it — and makes it /// impossible for a read to corrupt subscription state. The reference source-of-truth is /// unchanged either way: both paths coerce through the same /// logic, so a read and a subscription report a /// given observation identically. /// /// /// Nothing but genuine caller cancellation crosses this boundary. An unreachable /// Agent, a blown deadline, an unusable answer — each fails the batch's values /// (every reference codes ) rather than the batch: an /// exception here would also fail the references that were perfectly readable, and the /// node-manager cannot tell a thrown read from a broken driver. This is the opposite /// posture to , which is specified to throw so the /// runtime's retry/backoff loop can own recovery. /// /// /// The status-code distinctions the index draws — BadWaitingForInitialData for an /// authored-but-unreported id, BadNodeIdUnknown for an unknown one, /// BadNoCommunication for the Agent's UNAVAILABLE — are passed through /// unmodified. They tell an operator three different things. /// /// /// /// is null — a caller bug, not device data, and the /// one thing this method is loud about (matching 's /// documented posture). /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); // Checked before anything else: an empty batch must cost the Agent nothing, and there is no // failure it could report. if (fullReferences.Count == 0) { return []; } // One read of the session reference: the client used below is the one that was live at this // instant, never a half-swapped pairing (see the _session remarks). var client = Volatile.Read(ref _session)?.Client; var options = _options; if (client is null) { // Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left // exactly as it is: a read attempted against a driver that was never started (or was // deliberately stopped) is not evidence of degradation. return BadBatch(fullReferences.Count, BadNotConnected); } try { var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, cancellationToken) .ConfigureAwait(false); // Built from the SAME authored tags as the shared index, so the authored-vs-unknown // distinction and every coercion behave identically — it is a snapshot, not a variant. var snapshot = new MTConnectObservationIndex(options.Tags); snapshot.Apply(current); var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < results.Length; i++) { // Get never throws and never returns null, including for a null/blank reference. results[i] = snapshot.Get(fullReferences[i]); } WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); return results; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // The caller asked us to stop. This is the ONLY exception that may propagate: a blown // per-call deadline arrives as the TimeoutException BoundedAsync raises instead, and is // handled below as the Agent failure it is. throw; } catch (Exception ex) { DegradeAfterReadFailure(ex); return BadBatch(fullReferences.Count, BadCommunicationError); } } /// /// 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. /// /// /// Lock-free by design — see the remarks for why this does not take /// . It reads the session once, fetches against that /// session's client, and re-caches only if that same session is still installed, so a /// concurrent / can neither be /// undone by a late re-cache nor leak a model belonging to a client that is gone. A teardown /// that lands mid-fetch surfaces as the below rather /// than as a raw from the disposed client. /// /// Cancellation token. /// The Agent's device model. /// /// The driver is not initialized, or it was torn down while this fetch was in flight. /// internal async Task GetOrFetchProbeModelAsync(CancellationToken cancellationToken) { var session = Volatile.Read(ref _session) ?? throw NotConnected(); if (session.ProbeModel is not null) { return session.ProbeModel; } MTConnectProbeModel model; try { model = await BoundedAsync(session.Client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken) .ConfigureAwait(false); } catch (ObjectDisposedException ex) { // The lock-free window the _session remarks name: a teardown disposed this client after // we captured it. Reported as the not-connected error the caller already handles, so // browse has exactly one failure mode to reason about. throw NotConnected(ex); } // Publish only onto the session we actually fetched against. var recached = session with { ProbeModel = model, ProbeModelDataItemCount = CountDataItems(model) }; _ = Interlocked.CompareExchange(ref _session, recached, session); return model; } private InvalidOperationException NotConnected(Exception? inner = null) => new($"MTConnect driver '{_driverInstanceId}' has no live agent client; the /probe device model cannot be fetched unless InitializeAsync has succeeded and the driver has not since been shut down or re-initialized.", inner); /// /// 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; built = null; // One publish, so a concurrent lock-free reader sees the new client and the new probe // cache together or neither of them. Volatile.Write(ref _session, new AgentSession(client, probe, CountDataItems(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() { // Retire the whole session in one write, BEFORE disposing: a lock-free reader that still // holds the old reference gets a disposed client (caught + named at each seam), never a // session that is half-torn-down. var session = Volatile.Read(ref _session); Volatile.Write(ref _session, null); SafeDispose(session?.Client); _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. /// /// /// ⚠️ This runs while is held. Whatever Task 11 fills it with /// — cancelling the pump's token, awaiting its task — must not call back into /// , or /// , directly or by awaiting a pump that is itself blocked on one /// of them: the semaphore is non-reentrant and the result is a permanent hang with no /// exception and no log. See the remarks for the re-baseline /// pattern that avoids this. /// private static Task StopSampleStreamAsync() => Task.CompletedTask; /// /// Resolves the options a lifecycle call should apply, reporting an unreadable document /// consistently for both entry points before any state is destroyed. /// /// /// /// One rule, deliberately shared by and /// : a config document that cannot be read never /// destroys working state; it faults only a driver that had none. /// /// /// With a live session the driver keeps serving its previous, valid configuration and /// health is left alone — rejecting the edit already fails the deployment /// (DriverInstanceActor turns the throw into ApplyResult(false)), and /// downing a working driver over an unreadable document is a far larger blast radius /// than the change that was refused. Publishing while /// still serving values would also be a lie in the other direction. /// /// /// With no live session there is nothing to protect and every tag is already dark, so /// the failure must reach as — /// otherwise the dashboard keeps showing whatever state the driver was in before, which /// is the failure-that-looks-like-success shape this codebase exists to avoid. /// /// private MTConnectDriverOptions ResolveIncomingOptions(string driverConfigJson) { try { return HasConfigBody(driverConfigJson) ? ParseOptions(_driverInstanceId, driverConfigJson) : _options; } catch (Exception ex) { if (Volatile.Read(ref _session) is not null) { _logger.LogError( ex, "MTConnect driver {DriverInstanceId} rejected a new driver config document and kept running its previous configuration.", _driverInstanceId); } else { WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)); _logger.LogError( ex, "MTConnect driver {DriverInstanceId} could not read its driver config document and has no running configuration to fall back on; the driver is Faulted.", _driverInstanceId); } throw; } } /// /// True when carries a real config body — i.e. a JSON /// object or array with at least one element. The bootstrapper always passes a /// populated document; tests and probe-only callers pass a semantically empty one, which /// keeps the constructor-supplied options. /// /// /// /// Emptiness is decided by parsing, not by string comparison. Matching the /// literals "{}" / "[]" misses every other spelling of the same document — /// "{ }", a pretty-printed "{\n}", "{ /* nothing yet */ }" — and /// each of those would then reach , fail the required-AgentUri /// check, and turn a semantically empty document into a cold-start fault or a rejected /// deployment. That is precisely the asymmetry the keep-the-constructor-options rule /// exists to prevent. /// /// /// A malformed document is emphatically NOT empty and returns true, so /// runs and produces the real, quoted parse error. Treating /// unreadable text as "no config supplied" would silently swallow a corrupt document and /// start the driver on stale options — a failure wearing success's clothes. /// /// private static bool HasConfigBody(string? driverConfigJson) { if (string.IsNullOrWhiteSpace(driverConfigJson)) { return false; } try { using var document = JsonDocument.Parse(driverConfigJson, DocumentOptions); return document.RootElement.ValueKind switch { JsonValueKind.Object => document.RootElement.EnumerateObject().Any(), JsonValueKind.Array => document.RootElement.EnumerateArray().Any(), // A bare `null` document is the JSON spelling of "nothing supplied". JsonValueKind.Null => false, // A scalar root is not a config object at all; let ParseOptions say so. _ => true, }; } catch (JsonException) { return true; } } /// /// 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; /// /// Counts every data item the device model declares. Done once, when the model is cached, /// rather than re-walking the tree on every 30 s poll. /// private static int CountDataItems(MTConnectProbeModel model) => 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."); } } /// /// One Bad-coded snapshot per requested reference — the shape every read failure degrades /// to. Arity is preserved because the caller indexes the result positionally against the /// references it asked for. /// private static DataValueSnapshot[] BadBatch(int count, uint status) { // No source timestamp: nothing was observed. The server timestamp is when we gave up. var serverTimestamp = DateTime.UtcNow; var results = new DataValueSnapshot[count]; for (var i = 0; i < count; i++) { results[i] = new DataValueSnapshot(null, status, null, serverTimestamp); } return results; } /// /// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and /// degrade, preserving LastSuccessfulRead — the "when did this driver last hear from /// the Agent" diagnostic. Never downgrades , which is a /// stronger, config-level signal that a transient read failure must not paper over. /// private void DegradeAfterReadFailure(Exception ex) { var current = ReadHealth(); if (current.State != DriverState.Faulted) { WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); } _logger.LogWarning( ex, "MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.", _driverInstanceId, _options.AgentUri); } /// /// Disposes an agent client without letting a disposal fault escape. Teardown runs on the /// failure path of , where a second exception would replace the /// real one — but the swallowed exception is still traced rather than vanishing: a /// client that cannot be released is how a socket-handle leak starts, and an empty catch is /// the only place in this driver where a failure would leave no evidence at all. Debug /// level, because it is diagnostic detail about an already-reported failure. /// private void SafeDispose(IMTConnectAgentClient? client) { if (client is null) { return; } try { client.Dispose(); } catch (Exception ex) { _logger.LogDebug( ex, "MTConnect driver {DriverInstanceId} swallowed a fault while disposing its agent client during teardown; the client may not have released its connections.", _driverInstanceId); } } 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())}.")); /// /// The live agent client and the /probe cache derived from it, as one immutable /// unit. Immutable so that publishing a change is a single reference swap — see the /// remarks for why the three values must move together. /// /// The agent client this session owns and will dispose on teardown. /// /// The cached device model, or null once has /// dropped it. Never null at the moment a session is first published. /// /// /// Data items declared by , counted once at cache time and /// always 0 when it is null. /// private sealed record AgentSession( IMTConnectAgentClient Client, MTConnectProbeModel? ProbeModel, int ProbeModelDataItemCount); // ---- 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; } } }