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.
This commit is contained in:
@@ -81,6 +81,18 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
|
||||
public Exception? CurrentFailure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, the <b>next</b> <c>/probe</c> parks here until the test completes it, then the
|
||||
/// gate clears itself so later calls answer immediately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="Probe"/> meanwhile and still tell the two documents apart.
|
||||
/// </remarks>
|
||||
public TaskCompletionSource? ProbeGate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <c>/current</c> 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; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _probeCallCount);
|
||||
|
||||
return ProbeFailure is null ? Task.FromResult(Probe) : Task.FromException<MTConnectProbeModel>(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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
Reference in New Issue
Block a user