From 26eb511a7e2bfff514e0b16fb8c9b072407aa1bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:52:29 -0400 Subject: [PATCH] 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. --- .../MTConnectDriver.cs | 388 ++++++++++++++---- .../CannedAgentClient.cs | 33 +- .../MTConnectDriverLifecycleTests.cs | 210 +++++++++- 3 files changed, 527 insertions(+), 104 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 71d11215..8dcc9f68 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -98,6 +98,17 @@ public sealed class MTConnectDriver : IDriver, IReadable 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; @@ -107,16 +118,76 @@ public sealed class MTConnectDriver : IDriver, IReadable /// / 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 IMTConnectAgentClient? _client; private MTConnectObservationIndex _index; - private MTConnectProbeModel? _probeModel; - private int _probeModelDataItemCount; 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 @@ -173,7 +244,7 @@ public sealed class MTConnectDriver : IDriver, IReadable /// dropped by . Prefer /// , which cannot observe the flushed hole. /// - internal MTConnectProbeModel? CachedProbeModel => _probeModel; + internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel; /// /// The Agent's instanceId as of the last successful /current. Task 13 watches @@ -198,28 +269,15 @@ public sealed class MTConnectDriver : IDriver, IReadable 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); - MTConnectDriverOptions options; - try - { - options = ResolveOptions(driverConfigJson); - } - catch (Exception ex) - { - // A config document that will not parse is just as fatal to a cold start as an - // unreachable Agent, and it must reach GetHealth() the same way — otherwise the - // dashboard keeps showing whatever state the driver was in before. - WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)); - _logger.LogError( - ex, - "MTConnect driver {DriverInstanceId} could not read its driver config document; the driver is Faulted.", - _driverInstanceId); - - throw; - } - await StartCoreAsync(options, existingClient: null, cancellationToken).ConfigureAwait(false); } finally @@ -257,27 +315,9 @@ public sealed class MTConnectDriver : IDriver, IReadable await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false); try { - MTConnectDriverOptions incoming; - try - { - incoming = ResolveOptions(driverConfigJson); - } - catch (Exception ex) - { - // Deliberately NOT Faulted, and deliberately before any teardown: the running - // driver is still serving its previous, valid configuration. Rejecting the edit - // fails the deployment (DriverInstanceActor turns this into ApplyResult(false)); - // downing a working driver over an unreadable document would be a far larger blast - // radius than the change that was refused. - _logger.LogError( - ex, - "MTConnect driver {DriverInstanceId} rejected a new driver config document and kept running its previous configuration.", - _driverInstanceId); + var incoming = ResolveIncomingOptions(driverConfigJson); - throw; - } - - var existing = _client; + var existing = Volatile.Read(ref _session)?.Client; await StopSampleStreamAsync().ConfigureAwait(false); @@ -333,7 +373,7 @@ public sealed class MTConnectDriver : IDriver, IReadable /// constants. /// public long GetMemoryFootprint() => - ((long)_probeModelDataItemCount * ProbeModelBytesPerDataItem) + ((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem) + ((long)ObservationIndex.Count * IndexBytesPerEntry) + ((long)_options.Tags.Count * TagBytesPerEntry); @@ -344,21 +384,36 @@ public sealed class MTConnectDriver : IDriver, IReadable /// (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 dropped = _probeModelDataItemCount; - - _probeModel = null; - _probeModelDataItemCount = 0; - - if (dropped > 0) + var session = Volatile.Read(ref _session); + if (session?.ProbeModel is null) { - _logger.LogInformation( - "MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.", - _driverInstanceId, dropped); + return Task.CompletedTask; } + 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; } @@ -419,7 +474,9 @@ public sealed class MTConnectDriver : IDriver, IReadable return []; } - var client = _client; + // 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) @@ -472,29 +529,54 @@ public sealed class MTConnectDriver : IDriver, IReadable /// 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. + /// + /// The driver is not initialized, or it was torn down while this fetch was in flight. + /// internal async Task GetOrFetchProbeModelAsync(CancellationToken cancellationToken) { - var cached = _probeModel; - if (cached is not null) + var session = Volatile.Read(ref _session) ?? throw NotConnected(); + + if (session.ProbeModel is not null) { - return cached; + return session.ProbeModel; } - var client = _client - ?? throw new InvalidOperationException( - $"MTConnect driver '{_driverInstanceId}' has no agent client; the /probe device model cannot be fetched before InitializeAsync succeeds."); + 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); + } - var model = await BoundedAsync(client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken) - .ConfigureAwait(false); - - CacheProbeModel(model); + // 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. /// @@ -603,10 +685,11 @@ public sealed class MTConnectDriver : IDriver, IReadable // ---- commit point: everything below is non-throwing bookkeeping ---- _options = options; - _client = client; built = null; - CacheProbeModel(probe); + // 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); @@ -649,12 +732,13 @@ public sealed class MTConnectDriver : IDriver, IReadable /// private Task TeardownCoreAsync() { - var client = _client; - _client = null; - SafeDispose(client); + // 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); - _probeModel = null; - _probeModelDataItemCount = 0; _agentInstanceId = null; // A fresh index rather than Clear(): the tag table it coerces against belongs to the options @@ -670,17 +754,95 @@ public sealed class MTConnectDriver : IDriver, IReadable /// 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; - /// The document's options when it carries a body; otherwise the ones already in force. - private MTConnectDriverOptions ResolveOptions(string driverConfigJson) => - HasConfigBody(driverConfigJson) ? ParseOptions(_driverInstanceId, driverConfigJson) : _options; + /// + /// 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. The - /// bootstrapper always passes a populated document; tests and probe-only callers pass - /// "{}", "[]" or blank, which keep the constructor-supplied options. + /// 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)) @@ -688,9 +850,26 @@ public sealed class MTConnectDriver : IDriver, IReadable return false; } - var trimmed = driverConfigJson.Trim(); + try + { + using var document = JsonDocument.Parse(driverConfigJson, DocumentOptions); - return trimmed is not "{}" and not "[]"; + 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; + } } /// @@ -706,13 +885,12 @@ public sealed class MTConnectDriver : IDriver, IReadable || current.SampleIntervalMs != incoming.SampleIntervalMs || current.SampleCount != incoming.SampleCount; - private void CacheProbeModel(MTConnectProbeModel model) - { - _probeModel = model; - - // Counted once here rather than re-walked on every 30 s footprint poll. - _probeModelDataItemCount = model.Devices.Sum(d => d.AllDataItems().Count()); - } + /// + /// 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 @@ -777,7 +955,15 @@ public sealed class MTConnectDriver : IDriver, IReadable _driverInstanceId, _options.AgentUri); } - private static void SafeDispose(IMTConnectAgentClient? client) + /// + /// 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) { @@ -788,9 +974,12 @@ public sealed class MTConnectDriver : IDriver, IReadable { client.Dispose(); } - catch (Exception) + catch (Exception ex) { - // Teardown runs on failure paths; a disposal fault must not replace the original error. + _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); } } @@ -847,6 +1036,25 @@ public sealed class MTConnectDriver : IDriver, IReadable 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 ---- /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 9953754b..71fe5e52 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -81,6 +81,18 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable /// When set, /current throws this instead of answering. public Exception? CurrentFailure { get; set; } + /// + /// When set, the next /probe parks here until the test completes it, then the + /// gate clears itself so later calls answer immediately. + /// + /// + /// This is how a test holds a request "in flight" across a concurrent lifecycle change — + /// a shutdown or a re-initialize landing mid-fetch — with no timers and no races of its own. + /// The answer is captured when the request lands, not when it completes, so a test can + /// change meanwhile and still tell the two documents apart. + /// + public TaskCompletionSource? ProbeGate { get; set; } + /// /// When set, /current does not answer until this source completes — an Agent that /// accepted the request and then went quiet. The wait is cancellation-observing, so the @@ -108,13 +120,30 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable public long? LastSampleFrom { get; private set; } /// - public Task ProbeAsync(CancellationToken ct) + public async Task ProbeAsync(CancellationToken ct) { ObjectDisposedException.ThrowIf(IsDisposed, this); ct.ThrowIfCancellationRequested(); Interlocked.Increment(ref _probeCallCount); - return ProbeFailure is null ? Task.FromResult(Probe) : Task.FromException(ProbeFailure); + if (ProbeFailure is not null) + { + throw ProbeFailure; + } + + // Captured now: the Agent answers with the document it held when the request landed. + var answer = Probe; + + if (ProbeGate is { } gate) + { + ProbeGate = null; + await gate.Task.WaitAsync(ct).ConfigureAwait(false); + + // A real client whose handler was disposed mid-request fails the request; so does this. + ObjectDisposedException.ThrowIf(IsDisposed, this); + } + + return answer; } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs index d08036b3..197f429e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -26,12 +26,18 @@ public sealed class MTConnectDriverLifecycleTests private static MTConnectDriverOptions Opts( string agentUri = AgentUri, string? deviceName = null, - int requestTimeoutMs = 5000) => + int requestTimeoutMs = 5000, + int heartbeatMs = 10000, + int sampleIntervalMs = 1000, + int sampleCount = 1000) => new() { AgentUri = agentUri, DeviceName = deviceName, RequestTimeoutMs = requestTimeoutMs, + HeartbeatMs = heartbeatMs, + SampleIntervalMs = sampleIntervalMs, + SampleCount = sampleCount, Tags = [ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), @@ -176,23 +182,45 @@ public sealed class MTConnectDriverLifecycleTests client.IsDisposed.ShouldBeTrue(); } - [Fact] - public async Task Initialize_rejects_a_non_positive_request_timeout_before_dialling() + /// + /// arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait + /// forever" (or "ask the Agent for nothing"). All four knobs share one + /// RequirePositive path, so all four are asserted — a guard that covered only the + /// one knob with a test is exactly how three of them would quietly lose the check. + /// + /// + /// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than + /// leaning on the production client's own constructor guard. + /// + [Theory] + [InlineData("RequestTimeoutMs")] + [InlineData("HeartbeatMs")] + [InlineData("SampleIntervalMs")] + [InlineData("SampleCount")] + public async Task Initialize_rejects_a_non_positive_timing_knob_before_dialling(string knob) { - // arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait forever". - // Asserted through a FAKE client factory, so this proves the DRIVER validates rather than - // relying on the production client's own constructor guard. + var options = knob switch + { + "RequestTimeoutMs" => Opts(requestTimeoutMs: 0), + "HeartbeatMs" => Opts(heartbeatMs: 0), + "SampleIntervalMs" => Opts(sampleIntervalMs: 0), + "SampleCount" => Opts(sampleCount: 0), + _ => throw new ArgumentOutOfRangeException(nameof(knob), knob, "unhandled knob"), + }; + var factoryCalls = 0; - var driver = new MTConnectDriver(Opts(requestTimeoutMs: 0), "mt1", _ => + var driver = new MTConnectDriver(options, "mt1", _ => { factoryCalls++; return CannedAgentClient.FromFixtures(); }); - await Should.ThrowAsync( + var thrown = await Should.ThrowAsync( () => driver.InitializeAsync("{}", CancellationToken.None)); + // The message must name the offending key, or an operator cannot act on it. + thrown.Message.ShouldContain(knob); factoryCalls.ShouldBe(0); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } @@ -222,8 +250,24 @@ public sealed class MTConnectDriverLifecycleTests seen.DeviceName.ShouldBe("VMC-3Axis"); } - [Fact] - public async Task Initialize_with_an_empty_config_body_keeps_the_constructor_options() + /// + /// "Semantically empty" is decided by parsing, not by matching the literal two-character + /// spellings. A pretty-printer, a formatter, or a hand edit turns {} into { } + /// or {\n} without changing its meaning — and under literal matching each of those + /// reached ParseOptions, failed the required-AgentUri check, and turned an empty document + /// into a cold-start fault or a rejected deployment. + /// + [Theory] + [InlineData("{}")] + [InlineData("{ }")] + [InlineData("{\n}")] + [InlineData(" {\r\n\t} ")] + [InlineData("[]")] + [InlineData("[ ]")] + [InlineData("null")] + [InlineData("")] + [InlineData(" ")] + public async Task Initialize_with_a_semantically_empty_config_body_keeps_the_constructor_options(string json) { MTConnectDriverOptions? seen = null; var driver = new MTConnectDriver(Opts(), "mt1", o => @@ -233,11 +277,26 @@ public sealed class MTConnectDriverLifecycleTests return CannedAgentClient.FromFixtures(); }); - await driver.InitializeAsync("{}", CancellationToken.None); + await driver.InitializeAsync(json, CancellationToken.None); seen.ShouldNotBeNull(); seen!.AgentUri.ShouldBe(AgentUri); seen.Tags.Count.ShouldBe(2); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Initialize_treats_a_malformed_config_body_as_a_parse_error_not_as_empty() + { + // The other half of the emptiness rule: unreadable text must NOT fall through to "no config + // supplied" and silently start the driver on stale options. + var (driver, _) = NewDriver(); + + var thrown = await Should.ThrowAsync( + () => driver.InitializeAsync("""{"agentUri": """, CancellationToken.None)); + + thrown.Message.ShouldContain("not valid JSON"); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); } [Fact] @@ -251,6 +310,25 @@ public sealed class MTConnectDriverLifecycleTests driver.GetHealth().State.ShouldBe(DriverState.Faulted); } + [Fact] + public async Task Initialize_on_a_live_instance_with_an_unreadable_config_keeps_it_serving() + { + // One rule for both entry points: an unreadable document never destroys working state; it + // faults only a driver that had none. InitializeAsync IS reachable on a live instance + // (DriverInstanceActor re-initialises during Connecting/Reconnecting), so parsing must + // happen BEFORE the teardown — otherwise a bad edit kills a healthy client and the driver + // ends up in exactly the state ReinitializeAsync is written to avoid. + var (driver, client) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.InitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None)); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri); + client.IsDisposed.ShouldBeFalse(); + driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount); + } + [Fact] public async Task Initialize_parses_authored_tags_out_of_the_config_json() { @@ -480,7 +558,7 @@ public sealed class MTConnectDriverLifecycleTests // ---- footprint + flush ---- [Fact] - public async Task Memory_footprint_is_zero_before_initialize_and_positive_after() + public async Task Memory_footprint_grows_when_initialize_populates_the_caches() { var (driver, _) = NewDriver(); @@ -545,4 +623,112 @@ public sealed class MTConnectDriverLifecycleTests await Should.ThrowAsync( () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); } + + [Fact] + public async Task Fetching_the_probe_model_after_shutdown_is_rejected() + { + var (driver, _) = await InitializedDriverAsync(); + await driver.ShutdownAsync(CancellationToken.None); + + await Should.ThrowAsync( + () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); + } + + // ---- lock-free session snapshot: the probe fetch races the lifecycle ---- + + [Fact] + public async Task A_shutdown_landing_mid_fetch_surfaces_as_not_connected_not_as_object_disposed() + { + // Browse is deliberately lock-free (it awaits the network; holding the lifecycle lock would + // let it stall a shutdown for a whole RequestTimeoutMs), so a fetch CAN outlive the client + // it captured. What must not happen is a raw ObjectDisposedException escaping into browse — + // the caller already handles "not connected" and should have exactly one failure mode. + var (driver, client) = await InitializedDriverAsync(); + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + // Held locally: the gate is one-shot, so the client has already cleared its own reference by + // the time the request is parked on it. + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.ProbeGate = gate; + var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None); + + await driver.ShutdownAsync(CancellationToken.None); + client.IsDisposed.ShouldBeTrue(); + gate.TrySetResult(); + + var thrown = await Should.ThrowAsync(() => inFlight); + thrown.InnerException.ShouldBeOfType(); + } + + [Fact] + public async Task A_fetch_that_completes_after_a_reinitialize_does_not_overwrite_the_newer_cache() + { + // The probe cache is re-published only onto the session it was fetched against. Without + // that check, a slow browse would install a device model belonging to a superseded + // configuration over the one the re-initialize just established — stale browse output that + // nothing would ever correct. + var built = new List(); + var driver = new MTConnectDriver(Opts(), "mt1", _ => + { + var c = CannedAgentClient.FromFixtures(); + built.Add(c); + + return c; + }); + + await driver.InitializeAsync("{}", CancellationToken.None); + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + var client = built[0]; + var staleModel = client.Probe; + + // Park a fetch on the OLD (about to be superseded) session; it captures staleModel now. + // Held locally — the gate is one-shot and clears the client's own reference when it parks. + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.ProbeGate = gate; + var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None); + + // A config-only re-initialize: same client (so nothing is disposed), brand-new session. + var freshModel = new MTConnectProbeModel(staleModel.Devices); + client.Probe = freshModel; + await driver.ReinitializeAsync( + $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""", + CancellationToken.None); + + built.Count.ShouldBe(1); + driver.CachedProbeModel.ShouldBeSameAs(freshModel); + + // Now let the stale fetch finish. It still returns what the Agent gave it... + gate.TrySetResult(); + (await inFlight).ShouldBeSameAs(staleModel); + + // ...but it must not have published that over the newer session's cache. + driver.CachedProbeModel.ShouldBeSameAs(freshModel); + } + + [Fact] + public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session() + { + // Core polls the footprint and asks for a flush on its own schedule, so a flush arriving + // after the driver was torn down is ordinary, not exotic. It must observe the retired + // session and do nothing — publishing a "flushed" copy of it would reinstate a session + // holding an already-disposed client for every later caller. + // + // NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has + // no await point, so nothing can be interleaved between its read and its publish from a + // single-threaded test; the CAS is defence-in-depth against a genuinely concurrent + // lifecycle call and is deliberately claimed as such rather than as tested behaviour. The + // fetch-side CAS, which does have an await point, is covered by the test above. + var (driver, client) = await InitializedDriverAsync(); + + await driver.ShutdownAsync(CancellationToken.None); + await driver.FlushOptionalCachesAsync(CancellationToken.None); + + driver.CachedProbeModel.ShouldBeNull(); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + client.DisposeCount.ShouldBe(1); + + await Should.ThrowAsync( + () => driver.GetOrFetchProbeModelAsync(CancellationToken.None)); + } }