2d7dffcc42
Driver.MTConnect-005/-006/-007, each TDD'd red-first. - -005: _options was the one field at the StartCoreAsync commit point published by a plain write and read lock-free from ReadAsync/WriteAsync/DiscoverAsync/HostName/health. Now Volatile.Write/Volatile.Read like _session/_binding/_announcedInstanceId, pinned by a source scan (MTConnectOptionsPublicationTests) that fails on any unfenced touch. - -006: TryFormatValue guarded null and Array, but Convert.ToString renders ANY non-IConvertible value as its type name under a Good status. Guard is now the property the remark relies on; the accepted set (string/bool/numeric/DateTime) is unchanged. - -007: MTConnectAgentClient.Dispose was a check-then-set on a non-volatile bool, so two racing disposals both passed and the loser cancelled an already-disposed CTS. Now an Interlocked.Exchange claim over an int, with the request seams reading it volatile.
492 lines
22 KiB
C#
492 lines
22 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading.Channels;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|
|
|
/// <summary>
|
|
/// The shared <see cref="IMTConnectAgentClient"/> test double: an Agent that serves the canned
|
|
/// <c>Fixtures/probe.xml</c> + <c>Fixtures/current.xml</c> documents, counts every call, records
|
|
/// its own disposal, and lets a test drive the <c>/sample</c> chunk sequence by hand.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Deterministic by construction — no timers, no sleeps, no polling.</b> The
|
|
/// <c>/sample</c> leg reads from an unbounded <see cref="Channel{T}"/> that only a test
|
|
/// fills, and <see cref="PumpAsync"/> does not return until the driver has actually consumed
|
|
/// the chunk it wrote (each chunk carries its own completion source, signalled after the
|
|
/// enumerator's <c>yield return</c> resumes). A subscription test can therefore say "one
|
|
/// chunk has now been fully processed" as a fact rather than as a timing hope.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It honours the seam's stream-end contract</b> (see
|
|
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
|
|
/// enumeration ends without throwing. Closing the scripted stream via
|
|
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
|
|
/// production client does when an Agent drops the connection — so a pump written against the
|
|
/// fake cannot silently pass while mishandling the real one.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Disposal is observable</b> (<see cref="DisposeCount"/>) because "did the driver
|
|
/// actually release the client?" is a behaviour, not an implementation detail: a
|
|
/// <c>ReinitializeAsync</c> that re-points the Agent but leaks the old client keeps a live
|
|
/// connection pool per re-deploy, and nothing else in the test surface would notice.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|
{
|
|
private readonly CancellationTokenSource _disposeCts = new();
|
|
|
|
private readonly Lock _sampleWaitersLock = new();
|
|
private readonly List<(int Threshold, TaskCompletionSource Waiter)> _sampleWaiters = [];
|
|
|
|
/// <summary>Chunks a test has scripted but not yet pumped — see <see cref="ScriptChunks"/>.</summary>
|
|
private readonly ConcurrentQueue<MTConnectStreamsResult> _script = new();
|
|
|
|
/// <summary>
|
|
/// The <b>current</b> <c>/sample</c> stream generation. Replaced (not merely completed) by
|
|
/// <see cref="EndStream"/> so that a pump which reconnects after a dropped stream gets a
|
|
/// live channel to read from instead of one that is permanently closed — without that, a
|
|
/// reconnect test could only ever observe the reconnect failing.
|
|
/// </summary>
|
|
private Channel<ScriptedChunk> _chunks = Channel.CreateUnbounded<ScriptedChunk>();
|
|
|
|
private int _probeCallCount;
|
|
private int _currentCallCount;
|
|
private int _sampleCallCount;
|
|
private int _disposeCount;
|
|
|
|
private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
|
|
{
|
|
Probe = probe;
|
|
Current = current;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a client serving the repo's canned fixtures — <c>Fixtures/probe.xml</c> for
|
|
/// <c>/probe</c> and <c>Fixtures/current.xml</c> for <c>/current</c>.
|
|
/// </summary>
|
|
/// <param name="probeFixture">Probe-document fixture path, relative to the test binaries.</param>
|
|
/// <param name="currentFixture">Streams-document fixture path, relative to the test binaries.</param>
|
|
public static CannedAgentClient FromFixtures(
|
|
string probeFixture = "Fixtures/probe.xml",
|
|
string currentFixture = "Fixtures/current.xml") =>
|
|
new(
|
|
MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)),
|
|
MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture)));
|
|
|
|
/// <summary>Parses a streams fixture into a chunk a test can script onto the sample stream.</summary>
|
|
/// <param name="fixture">Streams-document fixture path, relative to the test binaries.</param>
|
|
public static MTConnectStreamsResult Chunk(string fixture) =>
|
|
MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
|
|
|
|
/// <summary>
|
|
/// An Agent scripted for the ring-buffer-overflow story: the driver primes from
|
|
/// <c>Fixtures/current.xml</c> (<c>nextSequence</c> 108), then the first scripted chunk is
|
|
/// <c>Fixtures/sample-gap.xml</c> — whose <c>firstSequence</c> (5000) is strictly newer than
|
|
/// the cursor, i.e. the buffer rolled past the driver — and the second is an ordinary chunk
|
|
/// that continues contiguously from the re-baseline.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <b>second</b> <c>/current</c> answer is what makes this a story rather than a single
|
|
/// event: it advertises the buffer the gap chunk revealed (<c>firstSequence</c> 5000 /
|
|
/// <c>nextSequence</c> 5005), so a driver that re-baselines and resumes from that answer sees
|
|
/// the follow-up chunk as contiguous. A driver that re-baselined but resumed from the stale
|
|
/// cursor would report a gap on every subsequent chunk instead.
|
|
/// </remarks>
|
|
public static CannedAgentClient WithGapThenResume()
|
|
{
|
|
var client = FromFixtures();
|
|
var gap = Chunk("Fixtures/sample-gap.xml");
|
|
|
|
// 1st /current = the InitializeAsync prime (the fixture). 2nd = the post-gap re-baseline.
|
|
client.CurrentAnswers.Enqueue(client.Current);
|
|
client.CurrentAnswers.Enqueue(
|
|
new MTConnectStreamsResult(gap.InstanceId, gap.NextSequence, gap.FirstSequence, gap.Observations));
|
|
|
|
client.ScriptChunks(
|
|
gap,
|
|
new MTConnectStreamsResult(
|
|
gap.InstanceId,
|
|
gap.NextSequence + 5,
|
|
gap.NextSequence,
|
|
[
|
|
new MTConnectObservation(
|
|
"dev1_pos", "201.5000", new DateTime(2026, 7, 24, 12, 6, 0, DateTimeKind.Utc)),
|
|
]));
|
|
|
|
return client;
|
|
}
|
|
|
|
/// <summary>The document <c>/probe</c> answers with. Settable so a test can re-shape the model.</summary>
|
|
public MTConnectProbeModel Probe { get; set; }
|
|
|
|
/// <summary>
|
|
/// The document <c>/current</c> answers with. Settable so a re-baseline (or an agent-restart
|
|
/// <c>instanceId</c> change) can be scripted between calls.
|
|
/// </summary>
|
|
public MTConnectStreamsResult Current { get; set; }
|
|
|
|
/// <summary>When set, <c>/probe</c> throws this instead of answering.</summary>
|
|
public Exception? ProbeFailure { get; set; }
|
|
|
|
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
|
|
public Exception? CurrentFailure { get; set; }
|
|
|
|
/// <summary>
|
|
/// Scripted <c>/current</c> answers, consumed in order: each call dequeues the next one and
|
|
/// <b>promotes it to <see cref="Current"/></b>, so once the script runs out the Agent keeps
|
|
/// answering with the last thing it said rather than travelling back in time. This is how a
|
|
/// test scripts "the prime, then the post-gap re-baseline" without racing the pump for a
|
|
/// property setter.
|
|
/// </summary>
|
|
public ConcurrentQueue<MTConnectStreamsResult> CurrentAnswers { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Scripted one-shot <c>/sample</c> failures: each enumeration dequeues one and throws it.
|
|
/// Models a transient stream fault the Agent recovers from — e.g. the
|
|
/// <see cref="InvalidDataException"/> a real cppagent's <c>OUT_OF_RANGE</c> error document
|
|
/// (served under HTTP 200) surfaces as.
|
|
/// </summary>
|
|
public ConcurrentQueue<Exception> SampleFailures { get; } = new();
|
|
|
|
/// <summary>
|
|
/// A <b>sticky</b> <c>/sample</c> failure: every enumeration throws it, forever. Models a
|
|
/// configuration-level fault such as
|
|
/// <see cref="MTConnectStreamNotSupportedException"/>, where reconnecting reproduces the
|
|
/// identical answer — the shape a pump must NOT retry-loop against.
|
|
/// </summary>
|
|
public Exception? SampleFailure { 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
|
|
/// caller's own deadline is what ends it; the test never sleeps and never races a timer it
|
|
/// did not set. Leave <c>null</c> for the ordinary immediate answer.
|
|
/// </summary>
|
|
public TaskCompletionSource? CurrentGate { get; set; }
|
|
|
|
/// <summary>
|
|
/// Signalled the moment a <c>/current</c> request <b>lands</b> — before
|
|
/// <see cref="CurrentGate"/> parks it. This is the deterministic "the caller is now inside
|
|
/// the request" barrier a test needs before landing a concurrent lifecycle change on it;
|
|
/// without it the only alternative is polling <see cref="CurrentCallCount"/> on a timer.
|
|
/// </summary>
|
|
public TaskCompletionSource? CurrentEntered { get; set; }
|
|
|
|
/// <summary>Number of <c>/probe</c> requests issued against this client.</summary>
|
|
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
|
|
|
|
/// <summary>Number of <c>/current</c> requests issued against this client.</summary>
|
|
public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
|
|
|
|
/// <summary>Number of <c>/sample</c> enumerations started against this client.</summary>
|
|
public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
|
|
|
|
/// <summary>Number of <see cref="Dispose"/> calls (not clamped — a double-dispose is visible).</summary>
|
|
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
|
|
|
/// <summary>Whether the client has been disposed at least once.</summary>
|
|
public bool IsDisposed => DisposeCount > 0;
|
|
|
|
/// <summary>The <c>from</c> sequence the most recent <c>/sample</c> enumeration was opened at.</summary>
|
|
public long? LastSampleFrom { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A task completing once <see cref="SampleCallCount"/> has reached
|
|
/// <paramref name="count"/> — the deterministic "the pump has now opened its Nth stream"
|
|
/// barrier, for the reconnect paths where no chunk is ever delivered and
|
|
/// <see cref="PumpAsync"/> therefore cannot be the barrier.
|
|
/// </summary>
|
|
/// <param name="count">The enumeration count to wait for.</param>
|
|
public Task WaitForSampleCallsAsync(int count)
|
|
{
|
|
lock (_sampleWaitersLock)
|
|
{
|
|
if (Volatile.Read(ref _sampleCallCount) >= count)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_sampleWaiters.Add((count, waiter));
|
|
|
|
return waiter.Task;
|
|
}
|
|
}
|
|
|
|
/// <summary>Releases any <see cref="WaitForSampleCallsAsync"/> waiter the new call count satisfies.</summary>
|
|
private void ReleaseSampleWaiters(int reached)
|
|
{
|
|
lock (_sampleWaitersLock)
|
|
{
|
|
for (var i = _sampleWaiters.Count - 1; i >= 0; i--)
|
|
{
|
|
if (_sampleWaiters[i].Threshold > reached)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_sampleWaiters[i].Waiter.TrySetResult();
|
|
_sampleWaiters.RemoveAt(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
|
ct.ThrowIfCancellationRequested();
|
|
Interlocked.Increment(ref _probeCallCount);
|
|
|
|
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/>
|
|
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
|
ct.ThrowIfCancellationRequested();
|
|
Interlocked.Increment(ref _currentCallCount);
|
|
CurrentEntered?.TrySetResult();
|
|
|
|
// The request was accepted; the answer is withheld until the test releases the gate or the
|
|
// caller's deadline cancels the token.
|
|
var gate = CurrentGate;
|
|
if (gate is not null)
|
|
{
|
|
await gate.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// A scripted answer supersedes — and then becomes — the standing one.
|
|
if (CurrentAnswers.TryDequeue(out var scripted))
|
|
{
|
|
Current = scripted;
|
|
}
|
|
|
|
return CurrentFailure is null ? Current : throw CurrentFailure;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
|
long from, [EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
|
ReleaseSampleWaiters(Interlocked.Increment(ref _sampleCallCount));
|
|
LastSampleFrom = from;
|
|
|
|
// Thrown before the first chunk, exactly like a real client that fails its /sample request:
|
|
// one-shot scripts first, then the sticky "this endpoint will never stream" failure.
|
|
if (SampleFailures.TryDequeue(out var oneShot))
|
|
{
|
|
throw oneShot;
|
|
}
|
|
|
|
if (SampleFailure is { } sticky)
|
|
{
|
|
throw sticky;
|
|
}
|
|
|
|
// Bound to THIS generation: EndStream swaps in a fresh channel, so a consumer that
|
|
// reconnects reads the new one rather than the closed one it just lost.
|
|
var chunks = Volatile.Read(ref _chunks);
|
|
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
|
var delivered = 0L;
|
|
|
|
while (true)
|
|
{
|
|
ScriptedChunk scripted;
|
|
try
|
|
{
|
|
scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
|
|
}
|
|
catch (ChannelClosedException)
|
|
{
|
|
// Mirrors the production client: a stream that stops for any reason other than the
|
|
// caller cancelling is an exception, never a quiet end of enumeration.
|
|
throw new MTConnectStreamEndedException(
|
|
MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered);
|
|
}
|
|
|
|
delivered++;
|
|
|
|
try
|
|
{
|
|
yield return scripted.Result;
|
|
}
|
|
finally
|
|
{
|
|
// Signalled when the consumer is DONE with this chunk — whether it came back for the
|
|
// next one or abandoned the enumeration. The finally is load-bearing: a chunk that
|
|
// makes the pump break out (a sequence gap, an agent restart) is never resumed past,
|
|
// so signalling only after the resume would hang every re-baseline test.
|
|
scripted.Consumed.TrySetResult();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every write this Agent was asked to take, in order — the record a driver-level write test
|
|
/// asserts against (which DataItem, on which device, carrying what).
|
|
/// </summary>
|
|
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
|
|
|
|
/// <summary>
|
|
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
|
|
/// refusal to script the Agent's <c>MTConnectError</c> leg.
|
|
/// </summary>
|
|
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
|
|
|
|
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
|
public Exception? WriteFailure { get; set; }
|
|
|
|
/// <summary>
|
|
/// Ran after a write has been recorded and before its outcome is returned. The deterministic
|
|
/// injection point for something that must happen BETWEEN two slots of one batch — cancelling
|
|
/// the batch's token, for instance — with no timing hope involved.
|
|
/// </summary>
|
|
public Action? AfterWrite { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
|
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
Writes.Enqueue((deviceName, dataItemId, value));
|
|
AfterWrite?.Invoke();
|
|
|
|
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
|
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
|
/// ran".
|
|
/// </summary>
|
|
/// <param name="chunk">The chunk the Agent should send next.</param>
|
|
/// <returns>A task completing once the enumerating consumer has moved past <paramref name="chunk"/>.</returns>
|
|
public Task PumpAsync(MTConnectStreamsResult chunk)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chunk);
|
|
|
|
var scripted = new ScriptedChunk(
|
|
chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
|
|
|
|
if (!Volatile.Read(ref _chunks).Writer.TryWrite(scripted))
|
|
{
|
|
throw new InvalidOperationException("The scripted /sample stream is already closed.");
|
|
}
|
|
|
|
return scripted.Consumed.Task;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues chunks for <see cref="PumpOnce"/> to deliver one at a time. Nothing is sent until
|
|
/// a test asks for it — the script is a plan, not a schedule.
|
|
/// </summary>
|
|
/// <param name="chunks">The chunks the Agent should send, in order.</param>
|
|
public void ScriptChunks(params MTConnectStreamsResult[] chunks)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chunks);
|
|
|
|
foreach (var chunk in chunks)
|
|
{
|
|
_script.Enqueue(chunk);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pumps the next scripted chunk (see <see cref="ScriptChunks"/>) and waits until the
|
|
/// consumer has finished with it.
|
|
/// </summary>
|
|
/// <returns>A task completing once the consumer has processed — or abandoned the stream on — that chunk.</returns>
|
|
/// <exception cref="InvalidOperationException">The script is exhausted.</exception>
|
|
public Task PumpOnce() =>
|
|
_script.TryDequeue(out var next)
|
|
? PumpAsync(next)
|
|
: throw new InvalidOperationException(
|
|
"No scripted /sample chunk left to pump; call ScriptChunks first.");
|
|
|
|
/// <summary>
|
|
/// Closes the current scripted <c>/sample</c> stream, which surfaces to its consumer as an
|
|
/// <see cref="MTConnectStreamEndedException"/> — the transient, reconnectable end — and opens
|
|
/// a fresh one so a reconnecting consumer has somewhere to land.
|
|
/// </summary>
|
|
public void EndStream()
|
|
{
|
|
var closed = Interlocked.Exchange(ref _chunks, Channel.CreateUnbounded<ScriptedChunk>());
|
|
closed.Writer.TryComplete();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The first-disposer test uses the value <see cref="Interlocked.Increment(ref int)"/>
|
|
/// returned</b>, not a re-read of the counter. Re-reading is a race: two concurrent
|
|
/// disposes can both increment and then both read 2, so BOTH take the early return and
|
|
/// the stream is never closed — a subscription test would then hang waiting for a
|
|
/// teardown that silently did nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b><see cref="_disposeCts"/> is cancelled but deliberately NOT disposed.</b> A
|
|
/// <c>/sample</c> enumeration that is starting concurrently reaches
|
|
/// <c>CreateLinkedTokenSource(ct, _disposeCts.Token)</c>, and reading <c>.Token</c> on a
|
|
/// disposed source throws <see cref="ObjectDisposedException"/> — a flake that would
|
|
/// surface as a random unrelated failure in whichever test happened to lose the race.
|
|
/// Leaking one cancelled source per fake is free; a cancelled source needs no disposal
|
|
/// to release anything a test cares about.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <summary>
|
|
/// Invoked on the first <see cref="Dispose"/> only. Lets a test observe driver state at the exact
|
|
/// instant the client becomes unusable — the window Gitea #517 is about, which is otherwise
|
|
/// invisible because it has closed again by the time <c>InitializeAsync</c> returns.
|
|
/// </summary>
|
|
public Action? OnFirstDispose { get; set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Increment(ref _disposeCount) > 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
OnFirstDispose?.Invoke();
|
|
_disposeCts.Cancel();
|
|
Volatile.Read(ref _chunks).Writer.TryComplete();
|
|
}
|
|
|
|
private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
|
|
}
|