Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
T
Joseph Doherty 26eb511a7e fix(mtconnect): review remediation for the driver lifecycle (Task 9)
Important 1 - the probe-model triple was read outside _lifecycle while every
write happened under it. Packed (Client, ProbeModel, ProbeModelDataItemCount)
into one immutable AgentSession swapped by a single Volatile.Write, matching
what _index already does, rather than locking the readers: both await the
network, so taking _lifecycle would stall a shutdown for a whole
RequestTimeoutMs and would widen the non-reentrancy hazard. GetOrFetch and
Flush re-publish via CompareExchange so a late re-cache cannot undo a
concurrent teardown/re-init, and a client disposed mid-fetch now surfaces as
the same "not connected" InvalidOperationException browse already handles
instead of a raw ObjectDisposedException.

Important 2 - HasConfigBody decided emptiness by matching the literals "{}" /
"[]", so "{ }", "{\n}" and every pretty-printed empty document fell through to
ParseOptions, failed the required-AgentUri check, and turned a semantically
empty config into a cold-start fault. Now parsed: an object/array with no
elements (or a bare null) is empty; malformed text is deliberately NOT empty so
ParseOptions produces the real quoted error rather than silently starting on
stale options.

Important 3 - documented the _lifecycle non-reentrancy hazard in the code, on
both the semaphore and StopSampleStreamAsync, naming Task 11's re-baseline as
the specific path that would deadlock and giving the CurrentAsync-directly
pattern that avoids it.

Important 4 - removed the Initialize/Reinitialize asymmetry rather than
documenting it. Both now share one rule via ResolveIncomingOptions: an
unreadable config document never destroys working state, and faults only a
driver that had none. InitializeAsync previously tore down before parsing, so a
bad document on a live instance destroyed a healthy client - the exact outcome
ReinitializeAsync was written to avoid.

Minors: SafeDispose traces the swallowed disposal fault at Debug (a client that
cannot be released is how a handle leak starts); the RequirePositive test is a
Theory over all four timing knobs, not just RequestTimeoutMs (arch-review
01/S-6); the footprint test no longer overclaims "is zero before initialize".

CannedAgentClient gains a one-shot ProbeGate so a lifecycle change landing
mid-request is deterministic - no timers. 346/346 (329 + 17). Six mutations
verified: fetch-CAS, disposed-translation, flush retired-session guard,
literal HasConfigBody, teardown-before-parse, and two dropped RequirePositive
calls each fail only their own tests.
2026-07-27 13:05:57 -04:00

1106 lines
55 KiB
C#

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;
/// <summary>
/// MTConnect Agent implementation of <see cref="IDriver"/> — the lifecycle half. Everything the
/// driver serves sits behind one <see cref="IMTConnectAgentClient"/>, so the whole class is
/// exercised against canned XML with no socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>The constructor opens nothing and builds nothing.</b> It does not even call
/// <c>agentClientFactory</c> — 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 <see cref="InitializeAsync"/>.
/// </para>
/// <para>
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c>
/// calls <see cref="ReinitializeAsync"/> 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 (<see cref="ParseOptions"/>) whenever it carries a real body,
/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
/// ignore the argument. A blank / <c>{}</c> / <c>[]</c> document means "no config supplied"
/// and keeps the constructor options, which is what unit tests and the factory path rely on.
/// </para>
/// <para>
/// <b>Failure is never dressed as success (#485).</b> A failed initialize disposes whatever
/// client it built, drops the probe cache and the observation index, publishes
/// <see cref="DriverState.Faulted"/> with the error text, and rethrows — the runtime's
/// retry/backoff loop is the recovery path. In particular a <c>/probe</c> that succeeds while
/// the priming <c>/current</c> fails is Faulted, not "Healthy with no data": the index IS the
/// read surface, so serving that state would render every tag
/// <c>BadWaitingForInitialData</c> indefinitely behind a green driver, and the
/// <c>/sample</c> cursor (Task 11) only exists because <c>/current</c> reported a
/// <c>nextSequence</c>.
/// </para>
/// <para>
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/> and
/// <see cref="IReadable"/>. <c>ISubscribable</c> (Task 11), <c>ITagDiscovery</c> (Task 12)
/// and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of
/// the state this class already caches — the probe model, the Agent <c>instanceId</c>, and
/// the observation index.
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
/// <see cref="ReadAsync"/>. That index has exactly one writer, and Task 11's <c>/sample</c>
/// pump is it.
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
/// for an <i>approximate</i> 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.
/// </summary>
private const int ProbeModelBytesPerDataItem = 512;
/// <summary>Estimated retained bytes per live <c>dataItemId → snapshot</c> entry.</summary>
private const int IndexBytesPerEntry = 256;
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
private const int TagBytesPerEntry = 256;
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
/// machine has no value — the two failures need different operator responses.
/// </summary>
private const uint BadCommunicationError = 0x80050000u;
/// <summary>
/// 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.
/// </summary>
private const uint BadNotConnected = 0x808A0000u;
/// <summary>
/// Config-JSON reader options, mirroring the sibling driver factories. Note there is
/// deliberately <b>no</b> <c>JsonStringEnumConverter</c>: enum-carrying DTO fields stay
/// <c>string?</c> and go through <see cref="ParseEnum{T}"/>, which is the project-wide
/// guard against the numerically-serialized-enum trap that faults a driver at deploy time.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>
/// Reader options for the emptiness probe in <see cref="HasConfigBody"/>. Kept in step with
/// <see cref="JsonOptions"/> 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).
/// </summary>
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private readonly string _driverInstanceId;
private readonly ILogger<MTConnectDriver> _logger;
private readonly Func<MTConnectDriverOptions, IMTConnectAgentClient> _agentClientFactory;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>⚠️ NON-REENTRANT — never call a public lifecycle method from code that already
/// holds this.</b> <see cref="SemaphoreSlim"/> has no owner tracking, so a re-entrant
/// call does not fail: it <b>deadlocks the driver permanently</b>, with no exception and
/// no log line to diagnose it from.
/// </para>
/// <para>
/// <b>This is aimed squarely at the <c>/sample</c> pump (Task 11).</b> Its ring-buffer
/// re-baseline ("on a sequence gap, re-<c>/current</c> and resume") is naturally written
/// as "just call the existing re-init logic" — and
/// <see cref="ReinitializeAsync"/>/<see cref="InitializeAsync"/> both take this
/// semaphore, so from inside the pump loop that is a hang, not a re-prime. Re-baseline
/// must instead call <see cref="IMTConnectAgentClient.CurrentAsync"/> directly on the
/// client the pump already holds (through <see cref="BoundedAsync"/>, to keep the
/// deadline) and <c>Apply</c> the result to <see cref="ObservationIndex"/>. Any future
/// shared step belongs in a <b>private, non-locking</b> helper that both the pump and the
/// lock-holding lifecycle methods can call — never in the public entry points.
/// </para>
/// <para>
/// The inverse direction is already safe: the lifecycle methods call
/// <see cref="StopSampleStreamAsync"/> while holding this, so that method — and whatever
/// Task 11 fills it with — must never re-enter the lifecycle either.
/// </para>
/// </remarks>
private readonly SemaphoreSlim _lifecycle = new(1, 1);
private MTConnectDriverOptions _options;
private MTConnectObservationIndex _index;
private long? _agentInstanceId;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
/// The live agent client plus the <c>/probe</c> cache derived from it, held as <b>one
/// immutable snapshot behind a single reference</b> and only ever replaced by a
/// <see cref="Volatile.Write{T}"/> of a whole new instance — the same discipline
/// <see cref="_index"/> uses, and for the same reason.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why one field instead of three.</b> These three values are read <i>outside</i>
/// <see cref="_lifecycle"/> — by <see cref="ReadAsync"/>, by
/// <see cref="GetOrFetchProbeModelAsync"/> (Task 12's browse), and by
/// <see cref="GetMemoryFootprint"/> — while every write happens under it. As three
/// separate fields they could be observed mid-update: a reader could see a dropped
/// <c>ProbeModel</c> 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.
/// </para>
/// <para>
/// <b>Why not simply take the lock in those readers.</b> Both readers <c>await</c> the
/// network, so they would hold the lifecycle lock across an HTTP round trip — a browse
/// could stall a <see cref="ShutdownAsync"/> for a full <c>RequestTimeoutMs</c> — and
/// every added lock site widens the non-reentrancy hazard documented on
/// <see cref="_lifecycle"/>. It would also make the read path inconsistent with
/// <see cref="ReadAsync"/>, which is deliberately lock-free.
/// </para>
/// <para>
/// <b>What this does NOT fix.</b> 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 <i>named</i>: the resulting
/// <see cref="ObjectDisposedException"/> 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.
/// </para>
/// </remarks>
private AgentSession? _session;
/// <summary>Creates a driver instance. Opens no connection and builds no agent client.</summary>
/// <param name="options">
/// The typed configuration this instance starts from. A config document supplied to
/// <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> supersedes it.
/// </param>
/// <param name="driverInstanceId">Stable logical id of this driver instance, from the config DB.</param>
/// <param name="agentClientFactory">
/// Builds the Agent client from the effective options. Defaults to the production
/// <see cref="MTConnectAgentClient"/>; tests inject a canned-XML fake. Called from
/// <see cref="InitializeAsync"/>, never from this constructor.
/// </param>
/// <param name="logger">Optional logger; defaults to the null logger.</param>
public MTConnectDriver(
MTConnectDriverOptions options,
string driverInstanceId,
Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null,
ILogger<MTConnectDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<MTConnectDriver>.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);
}
/// <inheritdoc/>
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc/>
public string DriverType => "MTConnect";
/// <summary>
/// The live <c>dataItemId → snapshot</c> map that backs the <b>subscription</b> surface:
/// primed by <see cref="InitializeAsync"/> and thereafter written only by the <c>/sample</c>
/// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed
/// it — and, in <c>MTConnectReadTests</c>, that <see cref="ReadAsync"/> leaves it alone.
/// </summary>
/// <remarks>
/// <b>Read does not consume this.</b> <see cref="ReadAsync"/> indexes its own <c>/current</c>
/// into a per-call snapshot instead, so the pump keeps a single writer — see the failure
/// analysis in <see cref="ReadAsync"/>'s remarks.
/// </remarks>
internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
/// <summary>
/// The cached <c>/probe</c> device model, or <c>null</c> when it has not been fetched or was
/// dropped by <see cref="FlushOptionalCachesAsync"/>. Prefer
/// <see cref="GetOrFetchProbeModelAsync"/>, which cannot observe the flushed hole.
/// </summary>
internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel;
/// <summary>
/// The Agent's <c>instanceId</c> as of the last successful <c>/current</c>. Task 13 watches
/// this for change to raise rediscovery (an Agent restart invalidates every cached id).
/// </summary>
internal long? AgentInstanceId => _agentInstanceId;
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
internal MTConnectDriverOptions EffectiveOptions => _options;
/// <inheritdoc/>
/// <remarks>
/// Builds the Agent client from the effective options, runs one deadline-bounded
/// <c>/probe</c> (caching the device model and its data-item count), then one
/// deadline-bounded <c>/current</c> to prime the observation index and record the Agent's
/// <c>instanceId</c>. Publishes <see cref="DriverState.Healthy"/> on success; on any failure
/// disposes the client, clears the caches, publishes <see cref="DriverState.Faulted"/> with
/// the error text, and rethrows.
/// </remarks>
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();
}
}
/// <inheritdoc/>
/// <remarks>
/// <para>
/// Stops the <c>/sample</c> stream, then takes one of two paths depending on whether the
/// new document changes anything the <see cref="IMTConnectAgentClient"/> reads at
/// <b>construction</b>:
/// </para>
/// <para>
/// <b>Endpoint/transport changed</b> (<c>AgentUri</c>, <c>DeviceName</c>,
/// <c>RequestTimeoutMs</c>, <c>HeartbeatMs</c>, <c>SampleIntervalMs</c>,
/// <c>SampleCount</c>) ⇒ 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
/// <c>/sample</c> 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.
/// </para>
/// <para>
/// <b>Otherwise</b> ("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 <c>/current</c> re-primes it. Nothing config-derived
/// survives, so a retyped tag cannot keep coercing to its old type.
/// </para>
/// </remarks>
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();
}
}
/// <inheritdoc/>
/// <remarks>
/// Stops the <c>/sample</c> 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. <c>LastSuccessfulRead</c> is retained because it is diagnostic ("when did
/// this driver last hear from the Agent"), not served data. Idempotent.
/// </remarks>
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();
}
}
/// <inheritdoc/>
public DriverHealth GetHealth() => ReadHealth();
/// <inheritdoc/>
/// <remarks>
/// Sums the three caches that scale with plant size: the <c>/probe</c> device model (by
/// declared data item), the live observation index (by indexed data item), and the authored
/// tag table. See <see cref="ProbeModelBytesPerDataItem"/> for why the weights are coarse
/// constants.
/// </remarks>
public long GetMemoryFootprint() =>
((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
+ ((long)_options.Tags.Count * TagBytesPerEntry);
/// <inheritdoc/>
/// <remarks>
/// Drops the <c>/probe</c> 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
/// <see cref="GetOrFetchProbeModelAsync"/>, which re-fetches and re-caches on a miss.
/// <para>
/// The drop is a single <see cref="Interlocked.CompareExchange{T}"/> of a whole new
/// <see cref="AgentSession"/>, so the model and its data-item count can never be seen
/// out of step (which would skew <see cref="GetMemoryFootprint"/>), and a flush that
/// races a concurrent teardown or re-initialize is discarded rather than resurrecting
/// the session it was reading.
/// </para>
/// </remarks>
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;
}
/// <inheritdoc/>
/// <remarks>
/// <para>
/// <b>One <c>/current</c> per batch, whatever the batch size.</b> MTConnect's
/// <c>/current</c> 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.
/// </para>
/// <para>
/// <b>The response is indexed into a per-call snapshot, NOT into the driver's shared
/// observation index.</b> That index is written by the <c>/sample</c> pump (Task 11) and
/// is deliberately last-write-wins with no timestamp arbitration. A read that folded its
/// <c>/current</c> into it would be a second writer racing the first, and the
/// <c>/current</c> document is frequently the <i>older</i> 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 <b>leaves it there until the data item next
/// changes</b>, which for an EVENT such as <c>Execution</c> 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
/// <see cref="MTConnectObservationIndex"/> logic, so a read and a subscription report a
/// given observation identically.
/// </para>
/// <para>
/// <b>Nothing but genuine caller cancellation crosses this boundary.</b> An unreachable
/// Agent, a blown deadline, an unusable answer — each fails the batch's <i>values</i>
/// (every reference codes <see cref="BadCommunicationError"/>) 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 <see cref="InitializeAsync"/>, which is specified to throw so the
/// runtime's retry/backoff loop can own recovery.
/// </para>
/// <para>
/// The status-code distinctions the index draws — <c>BadWaitingForInitialData</c> for an
/// authored-but-unreported id, <c>BadNodeIdUnknown</c> for an unknown one,
/// <c>BadNoCommunication</c> for the Agent's <c>UNAVAILABLE</c> — are passed through
/// unmodified. They tell an operator three different things.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">
/// <paramref name="fullReferences"/> is <c>null</c> — a caller bug, not device data, and the
/// one thing this method is loud about (matching <see cref="MTConnectObservationIndex"/>'s
/// documented posture).
/// </exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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);
}
}
/// <summary>
/// The <c>/probe</c> device model: the cached copy when warm, otherwise a fresh
/// deadline-bounded <c>/probe</c> that re-populates the cache. This is the accessor every
/// model consumer (Task 12's <c>DiscoverAsync</c>) must use, so that
/// <see cref="FlushOptionalCachesAsync"/> can never leave browse permanently disabled.
/// </summary>
/// <remarks>
/// Lock-free by design — see the <see cref="_session"/> remarks for why this does not take
/// <see cref="_lifecycle"/>. It reads the session <b>once</b>, fetches against that
/// session's client, and re-caches only if that same session is still installed, so a
/// concurrent <see cref="ShutdownAsync"/>/<see cref="ReinitializeAsync"/> 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 <see cref="InvalidOperationException"/> below rather
/// than as a raw <see cref="ObjectDisposedException"/> from the disposed client.
/// </remarks>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The Agent's device model.</returns>
/// <exception cref="InvalidOperationException">
/// The driver is not initialized, or it was torn down while this fetch was in flight.
/// </exception>
internal async Task<MTConnectProbeModel> 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);
/// <summary>
/// Builds driver options from a <c>DriverConfig</c> JSON document.
/// </summary>
/// <remarks>
/// 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
/// <c>MTConnectDriverFactoryExtensions.CreateInstance</c> should <b>delegate to this method</b>
/// rather than deserializing a second DTO — two parsers for one document drift.
/// </remarks>
/// <param name="driverInstanceId">Driver instance id, used only in error text.</param>
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
/// <returns>The parsed options.</returns>
/// <exception cref="InvalidOperationException">The document is unusable or omits <c>agentUri</c>.</exception>
internal static MTConnectDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
MTConnectDriverConfigDto? dto;
try
{
dto = JsonSerializer.Deserialize<MTConnectDriverConfigDto>(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) ----
/// <summary>
/// Brings the driver up against <paramref name="options"/>, reusing
/// <paramref name="existingClient"/> 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.
/// </summary>
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;
}
}
/// <summary>
/// Releases the agent client and drops every piece of served state. Never throws — it runs
/// on the failure path of <see cref="StartCoreAsync"/>, where a second exception would mask
/// the real one.
/// </summary>
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;
}
/// <summary>
/// Stops the shared <c>/sample</c> long-poll stream. A no-op until Task 11 introduces the
/// pump; it exists now because both <see cref="ReinitializeAsync"/> and
/// <see cref="ShutdownAsync"/> must stop the stream <b>before</b> the client they are about
/// to dispose goes away, and that ordering is easy to lose when the pump is bolted on later.
/// </summary>
/// <remarks>
/// <b>⚠️ This runs while <see cref="_lifecycle"/> is held.</b> Whatever Task 11 fills it with
/// — cancelling the pump's token, awaiting its task — must not call back into
/// <see cref="InitializeAsync"/>, <see cref="ReinitializeAsync"/> or
/// <see cref="ShutdownAsync"/>, 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 <see cref="_lifecycle"/> remarks for the re-baseline
/// pattern that avoids this.
/// </remarks>
private static Task StopSampleStreamAsync() => Task.CompletedTask;
/// <summary>
/// Resolves the options a lifecycle call should apply, reporting an unreadable document
/// consistently for both entry points before any state is destroyed.
/// </summary>
/// <remarks>
/// <para>
/// One rule, deliberately shared by <see cref="InitializeAsync"/> and
/// <see cref="ReinitializeAsync"/>: <b>a config document that cannot be read never
/// destroys working state; it faults only a driver that had none.</b>
/// </para>
/// <para>
/// With a live session the driver keeps serving its previous, valid configuration and
/// health is left alone — rejecting the edit already fails the deployment
/// (<c>DriverInstanceActor</c> turns the throw into <c>ApplyResult(false)</c>), and
/// downing a working driver over an unreadable document is a far larger blast radius
/// than the change that was refused. Publishing <see cref="DriverState.Faulted"/> while
/// still serving values would also be a lie in the other direction.
/// </para>
/// <para>
/// With no live session there is nothing to protect and every tag is already dark, so
/// the failure must reach <see cref="GetHealth"/> as <see cref="DriverState.Faulted"/> —
/// 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.
/// </para>
/// </remarks>
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;
}
}
/// <summary>
/// True when <paramref name="driverConfigJson"/> carries a real config body — i.e. a JSON
/// object or array with <b>at least one element</b>. The bootstrapper always passes a
/// populated document; tests and probe-only callers pass a semantically empty one, which
/// keeps the constructor-supplied options.
/// </summary>
/// <remarks>
/// <para>
/// <b>Emptiness is decided by parsing, not by string comparison.</b> Matching the
/// literals <c>"{}"</c> / <c>"[]"</c> misses every other spelling of the same document —
/// <c>"{ }"</c>, a pretty-printed <c>"{\n}"</c>, <c>"{ /* nothing yet */ }"</c> — and
/// each of those would then reach <see cref="ParseOptions"/>, 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.
/// </para>
/// <para>
/// <b>A malformed document is emphatically NOT empty</b> and returns <c>true</c>, so
/// <see cref="ParseOptions"/> 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.
/// </para>
/// </remarks>
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;
}
}
/// <summary>
/// Whether the new options change anything <see cref="IMTConnectAgentClient"/> reads at
/// construction, and therefore cannot be applied to a live client. See
/// <see cref="ReinitializeAsync"/> for why this is wider than the plan's AgentUri/DeviceName.
/// </summary>
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;
/// <summary>
/// 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 <see cref="GetMemoryFootprint"/> poll.
/// </summary>
private static int CountDataItems(MTConnectProbeModel model) =>
model.Devices.Sum(d => d.AllDataItems().Count());
/// <summary>
/// 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).
/// </summary>
private async Task<T> BoundedAsync<T>(
Func<CancellationToken, Task<T>> 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.");
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and
/// degrade, preserving <c>LastSuccessfulRead</c> — the "when did this driver last hear from
/// the Agent" diagnostic. Never downgrades <see cref="DriverState.Faulted"/>, which is a
/// stronger, config-level signal that a transient read failure must not paper over.
/// </summary>
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);
}
/// <summary>
/// Disposes an agent client without letting a disposal fault escape. Teardown runs on the
/// failure path of <see cref="StartCoreAsync"/>, where a second exception would replace the
/// real one — but the swallowed exception is still <b>traced</b> 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.
/// </summary>
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.");
}
}
/// <summary>Barrier-protected read of the cross-thread health snapshot.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
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<DriverDataType>(dto.DriverDataType, dto.FullName, driverInstanceId, nameof(MTConnectTagDefinition.DriverDataType)),
dto.IsArray ?? false,
dto.ArrayDim ?? 0,
dto.MtCategory,
dto.MtType,
dto.MtSubType,
dto.Units);
}
/// <summary>
/// Parses an enum authored as a <b>name</b>. 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.
/// </summary>
private static T ParseEnum<T>(string raw, string tagName, string driverInstanceId, string field)
where T : struct, Enum =>
Enum.TryParse<T>(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<T>())}."));
/// <summary>
/// The live agent client and the <c>/probe</c> cache derived from it, as one immutable
/// unit. Immutable so that publishing a change is a single reference swap — see the
/// <see cref="_session"/> remarks for why the three values must move together.
/// </summary>
/// <param name="Client">The agent client this session owns and will dispose on teardown.</param>
/// <param name="ProbeModel">
/// The cached device model, or <c>null</c> once <see cref="FlushOptionalCachesAsync"/> has
/// dropped it. Never null at the moment a session is first published.
/// </param>
/// <param name="ProbeModelDataItemCount">
/// Data items declared by <paramref name="ProbeModel"/>, counted once at cache time and
/// always <c>0</c> when it is <c>null</c>.
/// </param>
private sealed record AgentSession(
IMTConnectAgentClient Client,
MTConnectProbeModel? ProbeModel,
int ProbeModelDataItemCount);
// ---- config DTOs ----
/// <summary>
/// Nullable-init mirror of the driver's <c>DriverConfig</c> JSON. Every property is nullable
/// so an omitted key means "use the default" rather than "reset to zero".
/// </summary>
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<MTConnectTagDto>? Tags { get; init; }
public MTConnectProbeDto? Probe { get; init; }
public MTConnectReconnectDto? Reconnect { get; init; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
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; }
}
/// <summary>Background connectivity-probe knobs (Task 13 consumes them).</summary>
private sealed class MTConnectProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
}
/// <summary>Reconnect-backoff knobs (Task 11's pump consumes them).</summary>
private sealed class MTConnectReconnectDto
{
public int? MinBackoffMs { get; init; }
public int? MaxBackoffMs { get; init; }
public double? BackoffMultiplier { get; init; }
}
}