feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13)

This commit is contained in:
Joseph Doherty
2026-07-24 16:56:31 -04:00
parent 13dd9fcd70
commit fa0e40796f
2 changed files with 1094 additions and 9 deletions
@@ -42,21 +42,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>nextSequence</c>.
/// </para>
/// <para>
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/>,
/// <see cref="IReadable"/>, <see cref="ISubscribable"/> and <see cref="ITagDiscovery"/>.
/// <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. There is deliberately no <c>IWritable</c>: MTConnect is a read-only
/// protocol, which is also why every discovered leaf is
/// <b>Scope.</b> This type implements <see cref="IDriver"/>, <see cref="IReadable"/>,
/// <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
/// model). There is deliberately no <c>IWritable</c>: MTConnect is a read-only protocol,
/// which is also why every discovered leaf is
/// <see cref="SecurityClassification.ViewOnly"/>.
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
/// <see cref="ReadAsync"/>. That index has exactly one writer: the <c>/sample</c> pump
/// (<see cref="RunSampleStreamAsync"/>).
/// (<see cref="RunSampleStreamAsync"/>). The same single-writer discipline covers the two
/// Task-13 fields: the <b>connectivity probe loop</b> is the sole writer of
/// <see cref="HostState"/>, and it publishes <b>nothing</b> into
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
@@ -128,6 +131,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
private readonly ILogger<MTConnectDriver> _logger;
private readonly Func<MTConnectDriverOptions, IMTConnectAgentClient> _agentClientFactory;
/// <summary>
/// How the connectivity probe loop waits between ticks. Injected so the cadence is a seam
/// rather than a wall clock: the loop is a timer, and a suite that drove it by sleeping would
/// be flaky by construction. Defaults to <see cref="Task.Delay(TimeSpan, CancellationToken)"/>.
/// </summary>
private readonly Func<TimeSpan, CancellationToken, Task> _probeScheduler;
/// <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
@@ -183,6 +193,48 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <summary>Live subscriptions by id. Guarded by <see cref="_subscriptionLock"/>.</summary>
private readonly Dictionary<long, SubscriptionState> _subscriptions = [];
/// <summary>
/// Guards the two host-connectivity fields, which the probe loop writes and
/// <see cref="GetHostStatuses"/> reads from an arbitrary caller thread. Held for a field
/// read/write only — <b>never</b> across an await, and never while a status handler runs.
/// </summary>
private readonly Lock _probeLock = new();
/// <summary>
/// The Agent's last observed reachability. Written <b>only</b> by
/// <see cref="RunProbeLoopAsync"/>, so the state and the event stream can never disagree:
/// every value this field has ever held was announced by exactly one
/// <see cref="OnHostStatusChanged"/>. That is why a successful <see cref="InitializeAsync"/>
/// does not seed it <see cref="HostState.Running"/> — raising the event from there would run
/// caller code while the non-reentrant <see cref="_lifecycle"/> semaphore is held (the same
/// hazard that makes the pump, not <see cref="StartCoreAsync"/>, republish to subscribers),
/// and seeding it silently would leave a consumer that tracks the event stream disagreeing
/// with <see cref="GetHostStatuses"/>. <see cref="HostState.Unknown"/> until the first tick
/// is the honest answer. Guarded by <see cref="_probeLock"/>.
/// </summary>
private HostState _hostState = HostState.Unknown;
/// <summary>When <see cref="_hostState"/> last changed. Guarded by <see cref="_probeLock"/>.</summary>
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
/// <summary>
/// Cancels the connectivity probe loop. Written only under <see cref="_lifecycle"/>, which
/// serializes every start and stop of the loop.
/// </summary>
private CancellationTokenSource? _probeCts;
/// <summary>The connectivity probe loop's task, or <c>null</c> when none runs. See <see cref="_probeCts"/>.</summary>
private Task? _probeTask;
/// <summary>
/// The Agent <c>instanceId</c> the last <see cref="OnRediscoveryNeeded"/> was raised for —
/// seeded at the commit point of <see cref="StartCoreAsync"/> with the id the priming
/// <c>/current</c> reported. This is what makes rediscovery fire once per <b>change</b>
/// rather than once per chunk: every chunk after a restart carries the new id, and a
/// re-baseline that fails leaves the pump still holding the old one.
/// </summary>
private long _announcedInstanceId;
private MTConnectDriverOptions _options;
private MTConnectObservationIndex _index;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -263,11 +315,17 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <see cref="InitializeAsync"/>, never from this constructor.
/// </param>
/// <param name="logger">Optional logger; defaults to the null logger.</param>
/// <param name="probeScheduler">
/// How the connectivity probe loop waits between ticks. Defaults to
/// <see cref="Task.Delay(TimeSpan, CancellationToken)"/>; tests inject a hand-driven ticker so
/// the probe's cadence is asserted deterministically instead of slept through.
/// </param>
public MTConnectDriver(
MTConnectDriverOptions options,
string driverInstanceId,
Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null,
ILogger<MTConnectDriver>? logger = null)
ILogger<MTConnectDriver>? logger = null,
Func<TimeSpan, CancellationToken, Task>? probeScheduler = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -276,6 +334,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<MTConnectDriver>.Instance;
_agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
_probeScheduler = probeScheduler ?? Task.Delay;
// 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
@@ -333,6 +392,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
/// <summary>
/// The connectivity probe loop's task while one is running, else <c>null</c>. Exposed to
/// tests as the deterministic teardown barrier — "the probe loop has stopped" is a task
/// completion, never a sleep — and as the proof that <c>Probe.Enabled = false</c> starts
/// nothing at all.
/// </summary>
internal Task? ProbeLoopTask => Volatile.Read(ref _probeTask);
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
internal MTConnectDriverOptions EffectiveOptions => _options;
@@ -362,6 +429,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
// enumerating a disposed client reads the disposal as a dropped connection and
// reconnects against an object that no longer exists.
await StopSampleStreamAsync().ConfigureAwait(false);
await StopProbeLoopAsync().ConfigureAwait(false);
await TeardownCoreAsync().ConfigureAwait(false);
await StartCoreAsync(options, existingClient: null, cancellationToken).ConfigureAwait(false);
@@ -406,6 +474,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
var existing = Volatile.Read(ref _session)?.Client;
await StopSampleStreamAsync().ConfigureAwait(false);
await StopProbeLoopAsync().ConfigureAwait(false);
if (existing is null || RequiresClientRebuild(_options, incoming))
{
@@ -438,6 +507,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
var lastRead = ReadHealth().LastSuccessfulRead;
await StopSampleStreamAsync().ConfigureAwait(false);
await StopProbeLoopAsync().ConfigureAwait(false);
await TeardownCoreAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
@@ -969,6 +1039,351 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
private static bool IsCondition(string? category) =>
category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase);
// ---- IHostConnectivityProbe: one host per Agent, on its own cheap tick ----
/// <inheritdoc/>
/// <remarks>
/// <b>Raised from the probe loop's task, never under a lock and never under
/// <see cref="_lifecycle"/>.</b> A handler is arbitrary caller code: one that blocks forever
/// blocks the loop with it (and therefore the teardown that awaits it), exactly as an
/// <see cref="OnDataChange"/> handler does to the pump. One that <i>throws</i> is absorbed.
/// </remarks>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>
/// The single host this driver instance talks to, named by its configured
/// <see cref="MTConnectDriverOptions.AgentUri"/>. Mirrors <c>ModbusDriver.HostName</c>'s
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
/// Admin dashboard by endpoint rather than by driver-instance id.
/// </summary>
public string HostName => _options.AgentUri;
/// <inheritdoc/>
/// <remarks>
/// One entry, always — an Agent is a single host, and the driver knows of it whether or not
/// it has been reached. Before the first probe tick (and for the whole life of an instance
/// with <c>Probe.Enabled = false</c>) that entry reads <see cref="HostState.Unknown"/>, which
/// is the truth: nothing has measured it. See <see cref="_hostState"/> for why a successful
/// initialize does not seed it <see cref="HostState.Running"/>.
/// </remarks>
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
{
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
}
/// <summary>
/// Starts the periodic connectivity probe, if the operator asked for one. <b>Caller must hold
/// <see cref="_lifecycle"/>.</b>
/// </summary>
/// <param name="client">The client this session owns; the loop dials that one, never a later one.</param>
/// <param name="options">The options this session started with.</param>
private void StartProbeLoopCore(IMTConnectAgentClient client, MTConnectDriverOptions options)
{
if (!options.Probe.Enabled)
{
return;
}
var cts = new CancellationTokenSource();
_probeCts = cts;
Volatile.Write(ref _probeTask, Task.Run(() => RunProbeLoopAsync(client, options, cts.Token), CancellationToken.None));
}
/// <summary>
/// The connectivity probe: wait, ask the Agent whether it is answering, publish the verdict,
/// repeat. Never throws — it is a detached background task, and an escaping exception would
/// silently end host reporting while <see cref="GetHostStatuses"/> kept serving the last
/// thing it saw.
/// </summary>
/// <remarks>
/// <para>
/// <b>It calls <see cref="IMTConnectAgentClient.ProbeAsync"/> directly and publishes
/// nothing.</b> Both halves of that are deliberate.
/// </para>
/// <para>
/// Routing it through <see cref="GetOrFetchProbeModelAsync"/> is the obvious shortcut and
/// it is <b>inert</b>: that accessor answers from the cache <see cref="InitializeAsync"/>
/// already warmed, so the loop would issue no request at all and report
/// <see cref="HostState.Running"/> forever no matter what the Agent was doing — a feature
/// that ticks, logs, and measures nothing.
/// </para>
/// <para>
/// Publishing the answer into <see cref="AgentSession.ProbeModel"/> is the opposite
/// mistake: it would put a second writer on a field the lifecycle owns under a
/// compare-and-swap, and would silently re-warm a cache that
/// <see cref="FlushOptionalCachesAsync"/> — or an Agent restart, see
/// <see cref="AnnounceAgentRestart"/> — deliberately dropped. The parsed model is
/// discarded; only the fact that a request completed is used.
/// </para>
/// <para>
/// <b>Why <c>/probe</c> rather than the <c>/sample</c> heartbeat.</b> The heartbeat is
/// free, but it only exists while something is subscribed — an instance with no
/// subscriptions would report <see cref="HostState.Unknown"/> indefinitely, and host
/// reachability would become a function of OPC UA client behaviour. A dedicated tick is
/// the one signal that is true whether or not anyone is watching. Its cost is a parse of
/// the device model per interval, which is why <see cref="MTConnectProbeOptions.Interval"/>
/// exists.
/// </para>
/// <para>
/// <b>It waits first, then probes.</b> <see cref="StartCoreAsync"/> has just completed a
/// successful <c>/probe</c> one moment earlier; a tick that fired immediately would spend
/// a second identical request to learn nothing, and would do it on every re-initialize.
/// </para>
/// <para>
/// <b>It never calls a lifecycle method</b> — see the <see cref="_lifecycle"/> remarks.
/// <see cref="StopProbeLoopAsync"/> awaits this task while holding that semaphore, so
/// reaching back into one would be a permanent hang with no exception and no log line.
/// </para>
/// </remarks>
private async Task RunProbeLoopAsync(
IMTConnectAgentClient client, MTConnectDriverOptions options, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
try
{
await _probeScheduler(options.Probe.Interval, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (ct.IsCancellationRequested)
{
return;
}
bool reachable;
try
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(options.Probe.Timeout);
// The answer is deliberately discarded — see the remarks.
_ = await client.ProbeAsync(deadline.Token).ConfigureAwait(false);
reachable = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
// Unreachable, unusable, disposed, or past its deadline — from the host's point
// of view these are one fact, and the data paths report the detail.
reachable = false;
_logger.LogDebug(
ex,
"MTConnect driver {DriverInstanceId} could not reach {AgentUri} on its connectivity probe.",
_driverInstanceId, options.AgentUri);
}
TransitionHostTo(reachable ? HostState.Running : HostState.Stopped);
}
}
catch (Exception ex)
{
_logger.LogError(
ex,
"MTConnect driver {DriverInstanceId} stopped its connectivity probe on an unexpected fault; host status for {AgentUri} will not be updated until the driver is re-initialized.",
_driverInstanceId, options.AgentUri);
}
}
/// <summary>
/// Publishes a host state and announces it — <b>only when it actually changed</b>. Raising on
/// every tick would emit one event per interval per Agent forever: Core would re-fan Bad
/// quality across the host's subtree each time, and an operator watching the feed could not
/// tell a new outage from an ongoing one.
/// </summary>
private void TransitionHostTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState)
{
return;
}
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} host {AgentUri} went {Previous} -> {Current}.",
_driverInstanceId, HostName, old, newState);
// Outside the lock: a handler is caller code.
try
{
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} caught a fault from a host-status subscriber for {AgentUri}; the probe continues.",
_driverInstanceId, HostName);
}
}
/// <summary>
/// Stops the connectivity probe loop and waits for it to be gone. Idempotent, and never
/// throws — it runs on teardown paths where a second exception would mask the first.
/// </summary>
/// <remarks>
/// <b>⚠️ This runs while <see cref="_lifecycle"/> is held</b> and it awaits the loop, on
/// exactly the same terms as <see cref="StopSampleStreamAsync"/>: safe only because the loop
/// never touches the lifecycle and every await it makes observes the token cancelled below.
/// The one thing outside that guarantee is an <see cref="OnHostStatusChanged"/> handler that
/// blocks forever — a consumer bug this driver cannot paper over.
/// </remarks>
private async Task StopProbeLoopAsync()
{
var cts = _probeCts;
var task = Volatile.Read(ref _probeTask);
_probeCts = null;
Volatile.Write(ref _probeTask, null);
if (cts is null)
{
return;
}
try
{
await cts.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// Raced another stop; the loop is already going away.
}
if (task is not null)
{
try
{
await task.ConfigureAwait(false);
}
catch (Exception ex)
{
// RunProbeLoopAsync is written not to throw, so this is belt-and-braces.
_logger.LogDebug(
ex,
"MTConnect driver {DriverInstanceId} swallowed a fault from its connectivity probe while stopping it.",
_driverInstanceId);
}
}
cts.Dispose();
}
// ---- IRediscoverable: the Agent instanceId watch ----
/// <inheritdoc/>
/// <remarks>
/// <para>
/// <b>This event is what makes <see cref="RediscoverPolicy"/> =
/// <see cref="DiscoveryRediscoverPolicy.Once"/> safe.</b> The runtime runs exactly one
/// discovery pass per connect, so an Agent that restarts mid-run and renumbers, adds, or
/// drops data items would otherwise leave a stale address space sitting behind a
/// perfectly Healthy driver.
/// </para>
/// <para>
/// <b>Raised from the <c>/sample</c> pump's task, outside every lock.</b> A handler that
/// throws is absorbed and logged — a consumer bug must not tear down the stream serving
/// every subscriber. A handler that <i>blocks</i> blocks the pump, and one that
/// synchronously awaits a lifecycle method on this driver deadlocks it: teardown waits
/// for the pump, and the pump is waiting inside the handler. Core's consumer schedules
/// the rediscovery rather than running it inline, which is the only safe shape.
/// </para>
/// </remarks>
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <summary>
/// Handles an Agent restart observed on the wire: drop the device model it invalidated, then
/// tell Core to re-discover — <b>once per distinct <c>instanceId</c></b>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Dropping the cached <c>/probe</c> model is not housekeeping — it is what stops this
/// whole capability being inert.</b> Core's response to the event is to re-run
/// <see cref="DiscoverAsync"/>, which reads
/// <see cref="GetOrFetchProbeModelAsync"/>; with the cache still warm that call answers
/// from a model describing a process that no longer exists, and the "rediscovery" would
/// diff the stale tree against itself and change nothing. Dropped through the same
/// compare-and-swap <see cref="FlushOptionalCachesAsync"/> uses, so a lifecycle change
/// landing mid-drop wins rather than being undone.
/// </para>
/// <para>
/// <b>Once per change, not once per chunk.</b> Every chunk after a restart carries the new
/// id, and a re-baseline that fails leaves the pump still comparing against the old one —
/// so the guard is a latch on the id that was last announced, not on the pump's cursor
/// state. A second, different restart is a second announcement; an id the Agent reuses
/// after some other id is one too, because that is genuinely a new process.
/// </para>
/// </remarks>
/// <param name="instanceId">The <c>instanceId</c> the Agent's chunk reported.</param>
/// <param name="options">The options in force, for the log line's endpoint.</param>
private void AnnounceAgentRestart(long instanceId, MTConnectDriverOptions options)
{
if (Interlocked.Exchange(ref _announcedInstanceId, instanceId) == instanceId)
{
return;
}
InvalidateProbeModel();
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} dropped its cached /probe device model for {AgentUri} and raised rediscovery: agent instanceId is now {InstanceId}.",
_driverInstanceId, options.AgentUri, instanceId);
try
{
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs("MTConnect agent instanceId changed", null));
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} caught a fault from a rediscovery subscriber; the /sample stream continues.",
_driverInstanceId);
}
}
/// <summary>
/// Drops the cached <c>/probe</c> device model so the next consumer re-fetches it. Same
/// compare-and-swap discipline as <see cref="FlushOptionalCachesAsync"/> — retried, because
/// unlike a flush this drop is a correctness requirement rather than a courtesy: losing the
/// race must not leave a model from a dead Agent installed.
/// </summary>
private void InvalidateProbeModel()
{
while (true)
{
var session = Volatile.Read(ref _session);
if (session?.ProbeModel is null)
{
return;
}
var dropped = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
if (ReferenceEquals(Interlocked.CompareExchange(ref _session, dropped, session), session))
{
return;
}
}
}
/// <summary>
/// Starts the shared <c>/sample</c> pump, if there is anything to pump and anything to pump
/// from. <b>Caller must hold <see cref="_subscriptionLock"/>.</b>
@@ -1166,6 +1581,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
ObservationIndex.Clear();
FanOut(SubscribedReferences(), ObservationIndex);
// The driver's own state is consistent by this point, so it is safe to hand the
// restart to arbitrary caller code — which may call straight back into
// DiscoverAsync.
AnnounceAgentRestart(chunk.InstanceId, options);
return await RebaselineAsync(client, options, expected, instanceId, ct).ConfigureAwait(false);
}
@@ -1632,6 +2052,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
RequirePositive(options.SampleIntervalMs, nameof(MTConnectDriverOptions.SampleIntervalMs));
RequirePositive(options.SampleCount, nameof(MTConnectDriverOptions.SampleCount));
// Same 01/S-6 guard for Task 13's two knobs, gated on the feature that reads them. A 0
// interval turns the "periodic" probe into an unthrottled hot loop against the Agent, and
// a 0 (or negative) timeout cancels every probe before it can be answered — pinning the
// host to Stopped forever and reporting an outage that does not exist. Checked only when
// probing is ON because a disabled probe reads neither value, and faulting a deployment
// over a field the driver never touches would buy nothing; flipping Enabled back on is a
// config edit, and ReinitializeAsync runs this same validation.
if (options.Probe.Enabled)
{
RequirePositive(options.Probe.Interval, $"{nameof(MTConnectDriverOptions.Probe)}.{nameof(MTConnectProbeOptions.Interval)}");
RequirePositive(options.Probe.Timeout, $"{nameof(MTConnectDriverOptions.Probe)}.{nameof(MTConnectProbeOptions.Timeout)}");
}
var client = existingClient;
if (client is null)
{
@@ -1650,6 +2083,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
_options = options;
built = null;
// The Agent this session talks to, as of now. A restart is measured against THIS, so a
// re-initialize against a different Agent process never re-announces the change the
// operator's own action caused.
Volatile.Write(ref _announcedInstanceId, current.InstanceId);
// One publish, so a concurrent lock-free reader sees the new client, the new probe
// cache, and the pump's opening cursor together or none of them.
Volatile.Write(
@@ -1678,6 +2116,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
StartSampleStreamCore(republishOnStart: true);
}
StartProbeLoopCore(client, options);
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.",
_driverInstanceId,
@@ -2084,6 +2524,22 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
/// <summary>
/// The <see cref="TimeSpan"/> half of the 01/S-6 guard, for the connectivity probe's two
/// knobs. A non-positive interval is an unthrottled loop; a non-positive timeout cancels
/// every request before it can be answered.
/// </summary>
private static void RequirePositive(TimeSpan value, string optionName)
{
if (value <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(MTConnectDriverOptions),
value,
$"{optionName} must be positive; a non-positive value would either spin the connectivity probe against the Agent or cancel every probe before it could be answered.");
}
}
/// <summary>Barrier-protected read of the cross-thread health snapshot.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);