feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506
@@ -385,6 +385,57 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "Ful
|
||||
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
|
||||
```
|
||||
|
||||
### Task 7 review remediation (post-`c46540ae`) — three contract changes Tasks 9/11/13 must build against
|
||||
|
||||
Code review of `c46540ae` approved the framing algorithm but moved the risk onto the **contract**. Three
|
||||
changes here are breaking relative to the sketch above; the rest are hardening.
|
||||
|
||||
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
|
||||
yielding "indefinitely, until `ct` is cancelled", but the implementation returned normally on EOF, on
|
||||
the closing `--boundary--`, and — worst — on a response that was not multipart at all, where it
|
||||
yielded one parsed snapshot and finished. A pump written to the documented contract has no reason to
|
||||
handle normal completion, so it would silently drop its subscription or hot-loop reconnecting; the
|
||||
non-multipart leg additionally reported a **configuration error as a healthy finished stream**, which
|
||||
is the #485 quiet-successful-termination shape aimed straight at Task 11. Every non-cancellation end
|
||||
now throws: `MTConnectStreamEndedException` (with `MTConnectStreamEndReason.ConnectionClosed` /
|
||||
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
|
||||
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
|
||||
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
|
||||
**not** yielded.
|
||||
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
|
||||
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
|
||||
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
|
||||
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
|
||||
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
|
||||
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
|
||||
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
|
||||
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
|
||||
alone does not cover it. Documented on the seam.
|
||||
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
|
||||
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
|
||||
nonsense (`"12"` for two entries) which the index would otherwise publish as Good, and only the
|
||||
parser can tell that apart from a legitimate space-bearing `Message` — no value-shape heuristic
|
||||
works. Deliberately **false for CONDITION** observations: their value comes from the element *name*,
|
||||
so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
|
||||
|
||||
Hardening in the same pass: disposal mid-enumeration now surfaces as `OperationCanceledException`
|
||||
(via an internal dispose-linked token) rather than a raw `ObjectDisposedException`/`IOException` that
|
||||
Task 9's re-init would otherwise inflict on an enumerating pump; `HeartbeatMs`, `SampleIntervalMs` and
|
||||
`SampleCount` join `RequestTimeoutMs` in construction-time positive-value validation (all three reach
|
||||
the query string, and an Agent answers `heartbeat=0` with an HTTP-200 `MTConnectError` that would name
|
||||
no config key); a `multipart/*` response with no `boundary` parameter fails fast instead of degrading
|
||||
into a watchdog timeout; the no-`Content-length` framing fallback logs a one-shot warning (the client
|
||||
now takes an optional `ILogger`); and the shared element/attribute reading rules moved into
|
||||
`MTConnectXml`, used by both parsers.
|
||||
|
||||
**Coverage gap closed, and worth remembering as a pattern.** Every multipart test served the whole body
|
||||
from one buffer, so **every framing test completed in a single `ReadAsync`** — the split-boundary path,
|
||||
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
|
||||
headline risk is framing. A `ChunkedStream` double returning N bytes per read (N = 1, 3, 7) now drives
|
||||
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
|
||||
split-boundary overlap, removing the split-header overlap, and collapsing `EnsureAsync`'s fill loop each
|
||||
fail **only** the new chunked tests — every pre-existing multipart test stays green under all three.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
|
||||
|
||||
@@ -16,7 +16,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
|
||||
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
|
||||
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
|
||||
/// needs to implement these three members.
|
||||
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
|
||||
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
||||
/// against this seam never has to reference the concrete client.
|
||||
/// </remarks>
|
||||
public interface IMTConnectAgentClient
|
||||
{
|
||||
@@ -38,13 +40,73 @@ public interface IMTConnectAgentClient
|
||||
|
||||
/// <summary>
|
||||
/// Opens an Agent <c>/sample</c> long-poll stream starting at sequence <paramref name="from"/>
|
||||
/// and yields one <see cref="MTConnectStreamsResult"/> per multipart chunk the Agent sends,
|
||||
/// indefinitely, until <paramref name="ct"/> is cancelled. Each yielded result's
|
||||
/// <see cref="MTConnectStreamsResult.FirstSequence"/> is load-bearing — the caller compares
|
||||
/// it against the requested <paramref name="from"/> to detect a ring-buffer sequence gap and
|
||||
/// re-baseline via <see cref="CurrentAsync"/>.
|
||||
/// and yields one <see cref="MTConnectStreamsResult"/> per multipart chunk the Agent sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The only way this enumeration ends without throwing is <paramref name="ct"/> being
|
||||
/// cancelled.</b> Every other way a stream can stop — the connection dropping, the Agent
|
||||
/// writing its closing boundary, or the endpoint turning out not to stream at all —
|
||||
/// throws an <see cref="MTConnectStreamException"/> naming which. A consumer therefore
|
||||
/// never has to treat "the enumeration finished" as an ambiguous signal, and cannot
|
||||
/// silently lose its subscription to a data path that quietly stopped (#485). See
|
||||
/// <see cref="MTConnectStreamEndedException"/> (transient — reconnect) and
|
||||
/// <see cref="MTConnectStreamNotSupportedException"/> (configuration — reconnecting will
|
||||
/// reproduce it forever).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Advancing the cursor.</b> After each chunk, the caller's next expected sequence is
|
||||
/// that chunk's <see cref="MTConnectStreamsResult.NextSequence"/> — not the
|
||||
/// <paramref name="from"/> this call was opened with. Feed that running value to
|
||||
/// <see cref="IsSequenceGap"/> and, on reconnect, to a fresh <c>SampleAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="from">The sequence number to resume streaming from.</param>
|
||||
/// <param name="ct">Cancellation token; cancelling ends the stream.</param>
|
||||
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
||||
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||
/// <paramref name="expectedFrom"/>, meaning observations between the two were evicted before
|
||||
/// the caller could read them and an incremental update would silently skip values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
|
||||
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
|
||||
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
|
||||
/// against the original <c>from</c> instead reports a gap on every chunk once the
|
||||
/// Agent's buffer has rolled past it — an endless <c>/current</c> re-baseline storm
|
||||
/// against a perfectly healthy stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Equality is not a gap: <c>firstSequence == expectedFrom</c> is the ordinary
|
||||
/// contiguous case where the Agent simply had nothing older to send.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This check alone does NOT cover every ring-buffer overflow.</b> It can only judge
|
||||
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
|
||||
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
|
||||
/// document served under <b>HTTP 200</b>, which this client surfaces as an
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
|
||||
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
|
||||
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
|
||||
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
|
||||
/// resume from that snapshot's <c>NextSequence</c>) just like a detected gap.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="expectedFrom">The sequence the caller expected this chunk to start at.</param>
|
||||
/// <param name="chunk">The chunk the Agent answered with.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> when observations were lost and the caller must re-baseline via
|
||||
/// <see cref="CurrentAsync"/> rather than trusting an incremental update.
|
||||
/// </returns>
|
||||
static bool IsSequenceGap(long expectedFrom, MTConnectStreamsResult chunk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chunk);
|
||||
|
||||
return chunk.FirstSequence > expectedFrom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
@@ -25,7 +26,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
|
||||
/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
|
||||
/// R2-01 says must never happen — so the streaming leg gets its own client with
|
||||
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below).
|
||||
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below). In
|
||||
/// production each client owns its own handler and connection pool; in tests both are built
|
||||
/// over one injected handler, which the caller owns.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
|
||||
@@ -41,61 +44,94 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// and the watchdog is what closes it here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A non-positive <c>RequestTimeoutMs</c> is rejected at construction so an
|
||||
/// operator-authored <c>0</c> can never come to mean "wait forever" (arch-review 01/S-6).
|
||||
/// <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
|
||||
/// A <c>0</c> must never come to mean "wait forever" (arch-review 01/S-6), and the three
|
||||
/// <c>/sample</c> query knobs additionally reach the wire: an Agent answers
|
||||
/// <c>heartbeat=0</c> with an <c>MTConnectError</c> under HTTP 200, which would otherwise
|
||||
/// surface as a parse failure that never names the offending config key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Disposal is safe against an in-flight enumeration.</b> Task 9 re-initializes the
|
||||
/// driver by disposing and rebuilding this client, which can happen while a pump is still
|
||||
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
|
||||
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
|
||||
/// caller is written to expect, every request runs under a token linked to an internal
|
||||
/// dispose token, so disposal surfaces as an ordinary
|
||||
/// <see cref="OperationCanceledException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
|
||||
/// status, or an unparseable body all throw. <c>InitializeAsync</c> (Task 9) is what catches
|
||||
/// that and moves the driver to Faulted.
|
||||
/// status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
|
||||
/// <see cref="MTConnectStreamException"/> for why the last of those is an exception rather
|
||||
/// than a normal end of enumeration. <c>InitializeAsync</c> (Task 9) is what catches these
|
||||
/// and moves the driver to Faulted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly HttpClient _streamHttp;
|
||||
private readonly CancellationTokenSource _disposeCts = new();
|
||||
private readonly ILogger? _logger;
|
||||
private readonly Uri _probeUri;
|
||||
private readonly Uri _currentUri;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
||||
/// <param name="logger">
|
||||
/// Optional logger. Used only for conditions an operator needs to see but which are not
|
||||
/// failures — today, the degraded <c>/sample</c> framing mode.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MTConnectDriverOptions.RequestTimeoutMs"/> is not positive.</exception>
|
||||
public MTConnectAgentClient(MTConnectDriverOptions options)
|
||||
: this(options, handler: null)
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
|
||||
/// <see cref="MTConnectDriverOptions.HeartbeatMs"/>,
|
||||
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/> or
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> is not positive.
|
||||
/// </exception>
|
||||
public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
|
||||
: this(options, handler: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions)"/>, but sends through
|
||||
/// the supplied handler so the request/response round trip can be exercised with no socket.
|
||||
/// The caller retains ownership of <paramref name="handler"/>.
|
||||
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
|
||||
/// sends through the supplied handler so the request/response round trip can be exercised
|
||||
/// with no socket. The caller retains ownership of <paramref name="handler"/>.
|
||||
/// </summary>
|
||||
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler)
|
||||
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.RequestTimeoutMs <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(options),
|
||||
options.RequestTimeoutMs,
|
||||
$"{nameof(MTConnectDriverOptions.RequestTimeoutMs)} must be positive; a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely.");
|
||||
}
|
||||
RequirePositive(
|
||||
options.RequestTimeoutMs,
|
||||
nameof(MTConnectDriverOptions.RequestTimeoutMs),
|
||||
"a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
|
||||
RequirePositive(
|
||||
options.HeartbeatMs,
|
||||
nameof(MTConnectDriverOptions.HeartbeatMs),
|
||||
"it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
|
||||
RequirePositive(
|
||||
options.SampleIntervalMs,
|
||||
nameof(MTConnectDriverOptions.SampleIntervalMs),
|
||||
"it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
|
||||
RequirePositive(
|
||||
options.SampleCount,
|
||||
nameof(MTConnectDriverOptions.SampleCount),
|
||||
"it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");
|
||||
|
||||
_logger = logger;
|
||||
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
|
||||
|
||||
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
|
||||
// HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
|
||||
// Agent and an idle one both emit at least one chunk per heartbeat. A non-positive
|
||||
// HeartbeatMs is clamped away rather than rejected — the window then collapses to
|
||||
// RequestTimeoutMs, which is still strictly positive, so the read can never be unbounded.
|
||||
_streamIdleTimeout = TimeSpan.FromMilliseconds(
|
||||
(2L * Math.Max(0, options.HeartbeatMs)) + options.RequestTimeoutMs);
|
||||
// Agent and an idle one both emit at least one chunk per heartbeat.
|
||||
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
||||
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
@@ -114,29 +150,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_streamHttp.Timeout = Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past what the caller asked for? True when the
|
||||
/// chunk's oldest retained sequence is <i>strictly</i> newer than the sequence the caller
|
||||
/// requested, which means observations between the two were evicted before the caller could
|
||||
/// read them. Equality is not a gap: <c>firstSequence == requestedFrom</c> is the ordinary
|
||||
/// contiguous case where the Agent simply had nothing older to send.
|
||||
/// </summary>
|
||||
/// <param name="requestedFrom">The <c>from</c> sequence the caller passed to <see cref="SampleAsync"/>.</param>
|
||||
/// <param name="chunk">The chunk the Agent answered with.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> when observations were lost and the caller must re-baseline via
|
||||
/// <see cref="CurrentAsync"/> rather than trusting an incremental update.
|
||||
/// </returns>
|
||||
public static bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chunk);
|
||||
|
||||
return chunk.FirstSequence > requestedFrom;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
||||
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
|
||||
@@ -149,6 +167,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
@@ -161,34 +181,50 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var uri = BuildSampleUri(from);
|
||||
|
||||
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
|
||||
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
var live = lifetime.Token;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
|
||||
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
|
||||
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
|
||||
using var response = await SendStreamRequestAsync(request, uri, ct).ConfigureAwait(false);
|
||||
using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);
|
||||
|
||||
var boundary = ResolveMultipartBoundary(response);
|
||||
var boundary = ResolveMultipartBoundary(response, uri);
|
||||
if (boundary is null)
|
||||
{
|
||||
// Not a multipart stream: the Agent answered with a single document. This is the shape
|
||||
// an MTConnectError takes — Agents serve one under HTTP 200 — so the parse below is what
|
||||
// surfaces the Agent's own error text rather than an empty, successful-looking stream.
|
||||
yield return MTConnectStreamsParser.Parse(await ReadWholeBodyAsync(body, uri, ct).ConfigureAwait(false));
|
||||
// Not a multipart stream: the Agent answered with a single document, which means it
|
||||
// ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
|
||||
// exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
|
||||
// more useful than ours — then report the misconfiguration. The parsed snapshot is
|
||||
// deliberately NOT yielded: one document followed by a healthy-looking finished stream
|
||||
// is how a dead data path disguises itself as a working one (#485).
|
||||
var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
|
||||
_ = MTConnectStreamsParser.Parse(single);
|
||||
|
||||
yield break;
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri);
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
|
||||
var delivered = 0L;
|
||||
while (true)
|
||||
{
|
||||
var part = await reader.ReadNextPartAsync(ct).ConfigureAwait(false);
|
||||
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
|
||||
if (part is null)
|
||||
{
|
||||
yield break;
|
||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
||||
live.ThrowIfCancellationRequested();
|
||||
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
@@ -200,6 +236,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
delivered++;
|
||||
|
||||
yield return MTConnectStreamsParser.Parse(part);
|
||||
}
|
||||
}
|
||||
@@ -207,8 +245,30 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
|
||||
// than a disposed handler.
|
||||
_disposeCts.Cancel();
|
||||
_http.Dispose();
|
||||
_streamHttp.Dispose();
|
||||
_disposeCts.Dispose();
|
||||
}
|
||||
|
||||
private static void RequirePositive(int value, string optionName, string because)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(MTConnectDriverOptions),
|
||||
value,
|
||||
$"{optionName} must be positive; {because}.");
|
||||
}
|
||||
}
|
||||
|
||||
private Uri BuildSampleUri(long from) =>
|
||||
@@ -217,7 +277,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
HttpResponseMessage response;
|
||||
@@ -225,11 +286,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
// The caller did not cancel, so this is our own deadline (or HttpClient.Timeout) firing.
|
||||
// Reported as a TimeoutException because a bare "A task was canceled" tells an operator
|
||||
// nothing about which Agent stopped answering.
|
||||
// Neither the caller nor disposal cancelled, so this is our own deadline (or
|
||||
// HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
|
||||
// canceled" tells an operator nothing about which Agent stopped answering.
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
@@ -243,9 +304,9 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendStreamRequestAsync(
|
||||
HttpRequestMessage request, Uri uri, CancellationToken ct)
|
||||
HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
HttpResponseMessage response;
|
||||
@@ -255,7 +316,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
@@ -266,9 +327,9 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
|
||||
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationToken ct)
|
||||
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var reader = new StreamReader(body, Encoding.UTF8);
|
||||
@@ -276,7 +337,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
@@ -289,7 +350,14 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
/// Agent answered with a single (non-multipart) document.
|
||||
/// </summary>
|
||||
private static string? ResolveMultipartBoundary(HttpResponseMessage response)
|
||||
/// <exception cref="MTConnectStreamNotSupportedException">
|
||||
/// The response claims to be <c>multipart/*</c> but carries no <c>boundary</c> parameter, so
|
||||
/// there is nothing to frame it by. Failed fast and named, because the alternative — falling
|
||||
/// through to whole-body reading of a body that never ends — surfaces much later as a bare
|
||||
/// watchdog <see cref="TimeoutException"/> that points an operator at the network instead of
|
||||
/// at the malformed header.
|
||||
/// </exception>
|
||||
private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType;
|
||||
if (contentType?.MediaType is null ||
|
||||
@@ -302,7 +370,13 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
|
||||
.Trim('"');
|
||||
|
||||
return string.IsNullOrEmpty(boundary) ? null : boundary;
|
||||
if (string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||
@@ -335,18 +409,22 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
|
||||
/// dependency set, and TrakHound's
|
||||
/// <c>MTConnectHttpClientStream</c> — the one candidate the plan left open — takes a URL
|
||||
/// and dials its own socket, so it can neither be fed an existing response stream nor be
|
||||
/// exercised without a listener. See the Task 0 correction in the plan.
|
||||
/// dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
|
||||
/// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
|
||||
/// existing response stream nor be exercised without a listener. See the Task 0
|
||||
/// correction in the plan.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
|
||||
/// a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
|
||||
/// by length and the chunk is emitted the instant it is complete. Without it there is no
|
||||
/// way to know a part has ended except by seeing the <i>next</i> boundary, so that path
|
||||
/// necessarily emits each chunk one chunk late; it exists as a correctness fallback, not
|
||||
/// as the expected path.
|
||||
/// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
|
||||
/// can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
|
||||
/// correctness fallback, not the expected path, and it logs a one-shot warning when it
|
||||
/// engages so the degradation is visible rather than merely slow. (It cannot strand the
|
||||
/// final part indefinitely — a clean end of stream returns the buffered tail — though a
|
||||
/// watchdog timeout on a stalled Agent does discard it.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
|
||||
@@ -354,7 +432,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// malfunctioning Agent cannot grow it without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MultipartStreamReader(Stream stream, string boundary, TimeSpan idleTimeout, Uri uri)
|
||||
private sealed class MultipartStreamReader(
|
||||
Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
|
||||
{
|
||||
private const int MaxBufferBytes = 32 * 1024 * 1024;
|
||||
|
||||
@@ -365,10 +444,14 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
private byte[] _buffer = new byte[16 * 1024];
|
||||
private int _length;
|
||||
private bool _finished;
|
||||
private bool _warnedNoContentLength;
|
||||
|
||||
/// <summary>How the stream ended. Only meaningful once a read has returned <c>null</c>.</summary>
|
||||
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
|
||||
/// the Agent closes the connection.
|
||||
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -380,42 +463,65 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
|
||||
!await EnsureAsync(2, ct).ConfigureAwait(false))
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
}
|
||||
|
||||
// "--boundary--" closes the stream.
|
||||
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
return Finish(MTConnectStreamEndReason.ClosingBoundary);
|
||||
}
|
||||
|
||||
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
|
||||
if (headerEnd < 0)
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
}
|
||||
|
||||
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
|
||||
Consume(headerEnd);
|
||||
|
||||
var body = ParseContentLength(headers) is { } declared
|
||||
? await ReadByLengthAsync(declared, ct).ConfigureAwait(false)
|
||||
: await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||
byte[]? body;
|
||||
if (ParseContentLength(headers) is { } declared)
|
||||
{
|
||||
body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
WarnOnceAboutMissingContentLength();
|
||||
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return body is null ? null : Encoding.UTF8.GetString(body);
|
||||
}
|
||||
|
||||
private void WarnOnceAboutMissingContentLength()
|
||||
{
|
||||
if (_warnedNoContentLength)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_warnedNoContentLength = true;
|
||||
|
||||
logger?.LogWarning(
|
||||
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
|
||||
uri);
|
||||
}
|
||||
|
||||
private string? Finish(MTConnectStreamEndReason reason)
|
||||
{
|
||||
_finished = true;
|
||||
EndReason = reason;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
|
||||
{
|
||||
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
|
||||
{
|
||||
_finished = true;
|
||||
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -431,10 +537,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||
if (next < 0)
|
||||
{
|
||||
// EOF with no closing delimiter: whatever is buffered is the last part.
|
||||
// EOF with no closing delimiter: whatever is buffered is the last part. Returned
|
||||
// rather than dropped, so a clean end of stream cannot strand a chunk.
|
||||
var tail = _buffer[.._length];
|
||||
_length = 0;
|
||||
_finished = true;
|
||||
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
|
||||
return tail;
|
||||
}
|
||||
@@ -477,6 +584,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
var searchedTo = 0;
|
||||
while (true)
|
||||
{
|
||||
// Restart the scan far enough back that a delimiter split across two reads is still
|
||||
// found — without the overlap, a boundary straddling a packet boundary is invisible.
|
||||
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
|
||||
if (index >= 0)
|
||||
{
|
||||
|
||||
@@ -136,10 +136,33 @@ public sealed record MTConnectStreamsResult(
|
||||
/// The observation's Agent-reported timestamp, normalized to UTC (<see cref="DateTime.Kind"/>
|
||||
/// is always <see cref="DateTimeKind.Utc"/>). Becomes the OPC UA variable's SourceTimestamp.
|
||||
/// </param>
|
||||
/// <param name="IsStructured">
|
||||
/// <c>true</c> when the Agent carried this observation's real content in <b>child elements</b>
|
||||
/// rather than as text — an MTConnect 2.0 <c>DATA_SET</c> / <c>TABLE</c> observation, whose
|
||||
/// content is a list of <c><Entry key="…"></c> elements.
|
||||
/// <para>
|
||||
/// <b>Why the flag exists, and why only the parser can set it.</b> <see cref="Value"/> is
|
||||
/// the element's concatenated descendant text, so a two-entry data set reads as the single
|
||||
/// token <c>"12"</c> — keys discarded, values run together. The observation index cannot
|
||||
/// detect that after the fact: neither this record nor the tag definition carries the
|
||||
/// DataItem's <c>representation</c>, and no value-shape heuristic can work, because
|
||||
/// concatenated entries are indistinguishable from a legitimate space-bearing EVENT such as
|
||||
/// a <c>Message</c> reading <c>"Coolant level low"</c>. The one reliable discriminator —
|
||||
/// that the observation element had element children — exists only while the XML is still
|
||||
/// XML.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately a <b>neutral fact about the wire shape</b>, not a status: mapping it to a
|
||||
/// quality code is the observation index's job (it codes these
|
||||
/// <c>BadNotSupported</c> rather than publishing concatenated noise as Good). Defaults to
|
||||
/// <c>false</c>, the shape of every ordinary scalar observation.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record MTConnectObservation(
|
||||
string DataItemId,
|
||||
string Value,
|
||||
DateTime TimestampUtc);
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false);
|
||||
|
||||
/// <summary>
|
||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
@@ -30,12 +29,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||
/// <c><Components></c> is a component.
|
||||
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Elements are therefore
|
||||
/// matched on <see cref="XName.LocalName"/> and the namespace is ignored entirely — nothing
|
||||
/// version-specific is hard-coded, and a vendor-extension component in its own namespace
|
||||
/// (whose standard child elements still inherit the default MTConnect namespace) is not
|
||||
/// silently dropped. Attributes, by contrast, are read unqualified so a prefixed attribute
|
||||
/// (e.g. <c>xsi:type</c>) can never be mistaken for a DataItem's <c>type</c>.
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
|
||||
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
|
||||
/// (match on local name, read attributes unqualified) are shared verbatim with
|
||||
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
|
||||
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
|
||||
/// the Agent's document begins. A <c>/sample</c> chunk, by contrast, is lifted out of a
|
||||
/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
|
||||
/// the XML declaration, which <c>XmlReader</c> rejects — hence the trim there and not here.
|
||||
/// This asymmetry is intentional, not an oversight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
||||
@@ -54,6 +59,9 @@ internal static class MTConnectProbeParser
|
||||
/// </summary>
|
||||
private const int MaxComponentDepth = 64;
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "/probe response";
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
@@ -106,12 +114,12 @@ internal static class MTConnectProbeParser
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
|
||||
|
||||
if (!IsNamed(root, "MTConnectDevices"))
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
|
||||
{
|
||||
throw new InvalidDataException(DescribeUnexpectedRoot(root));
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
|
||||
}
|
||||
|
||||
var devicesContainer = ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
|
||||
|
||||
@@ -127,15 +135,15 @@ internal static class MTConnectProbeParser
|
||||
|
||||
private static MTConnectDevice ReadDevice(XElement element) =>
|
||||
new(
|
||||
RequiredAttribute(element, "id", "Device"),
|
||||
OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth: 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
|
||||
new(
|
||||
RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>"),
|
||||
OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth + 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
@@ -151,104 +159,31 @@ internal static class MTConnectProbeParser
|
||||
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
|
||||
}
|
||||
|
||||
return ChildrenNamed(container, "Components")
|
||||
return MTConnectXml.ChildrenNamed(container, "Components")
|
||||
.SelectMany(components => components.Elements())
|
||||
.Select(element => ReadComponent(element, depth))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
||||
ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => ChildrenNamed(dataItems, "DataItem"))
|
||||
MTConnectXml.ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
|
||||
.Select(ReadDataItem)
|
||||
.ToList();
|
||||
|
||||
private static MTConnectDataItem ReadDataItem(XElement element)
|
||||
{
|
||||
var id = RequiredAttribute(element, "id", "DataItem");
|
||||
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
|
||||
|
||||
return new MTConnectDataItem(
|
||||
id,
|
||||
OptionalAttribute(element, "name"),
|
||||
RequiredAttribute(element, "category", $"DataItem '{id}'"),
|
||||
RequiredAttribute(element, "type", $"DataItem '{id}'"),
|
||||
OptionalAttribute(element, "subType"),
|
||||
OptionalAttribute(element, "units"),
|
||||
OptionalAttribute(element, "representation"),
|
||||
OptionalIntAttribute(element, "sampleCount", id));
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "subType"),
|
||||
MTConnectXml.OptionalAttribute(element, "units"),
|
||||
MTConnectXml.OptionalAttribute(element, "representation"),
|
||||
MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
|
||||
}
|
||||
|
||||
private static bool IsNamed(XElement element, string localName) =>
|
||||
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
|
||||
|
||||
private static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
|
||||
parent.Elements().Where(child => IsNamed(child, localName));
|
||||
|
||||
private static string RequiredAttribute(XElement element, string name, string owner)
|
||||
{
|
||||
var value = OptionalAttribute(element, name);
|
||||
|
||||
return value ?? throw new InvalidDataException(
|
||||
$"MTConnect /probe response is malformed: {owner} has no '{name}' attribute.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// Only unqualified attributes are considered, so a namespace-prefixed attribute such as
|
||||
/// <c>xsi:type</c> can never be read as a DataItem's <c>type</c>.
|
||||
/// </summary>
|
||||
private static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static int? OptionalIntAttribute(XElement element, string name, string dataItemId)
|
||||
{
|
||||
var raw = OptionalAttribute(element, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /probe response is malformed: DataItem '{dataItemId}' has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message for a root element that is not <c>MTConnectDevices</c>, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document (which
|
||||
/// Agents return under HTTP 200 for, e.g., an unknown device name).
|
||||
/// </summary>
|
||||
private static string DescribeUnexpectedRoot(XElement root)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect /probe response root element is <{root.Name.LocalName}>, expected <MTConnectDevices>.";
|
||||
|
||||
if (!IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
{
|
||||
var code = OptionalAttribute(e, "errorCode");
|
||||
var text = e.Value.Trim();
|
||||
return code is null ? text : $"{code}: {text}";
|
||||
})
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for every way an Agent <c>/sample</c> long poll can stop delivering chunks for a
|
||||
/// reason that is <b>not</b> the caller cancelling it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
|
||||
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
|
||||
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
|
||||
/// an enumerator that ends immediately. Returning normally would make "the data path
|
||||
/// stopped" indistinguishable from "the data path is idle", which is the
|
||||
/// quiet-successful-looking-termination shape that produced this repo's #485 defect. Every
|
||||
/// non-cancellation end is therefore loud, typed, and carries the Agent URI.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Catch this base type to handle "the stream is over" uniformly; catch the two derived
|
||||
/// types to distinguish a <i>transient</i> end (the Agent closed a connection — reconnect)
|
||||
/// from a <i>configuration</i> error (the Agent will never stream to this request — retrying
|
||||
/// is pointless until an operator changes something).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class MTConnectStreamException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
protected MTConnectStreamException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
protected MTConnectStreamException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
protected MTConnectStreamException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How an Agent <c>/sample</c> stream ended.</summary>
|
||||
public enum MTConnectStreamEndReason
|
||||
{
|
||||
/// <summary>
|
||||
/// The transport closed mid-stream — the connection dropped, the Agent restarted, or a proxy
|
||||
/// timed the connection out. Ordinary and transient: reconnect from the last
|
||||
/// <see cref="MTConnectStreamsResult.NextSequence"/>.
|
||||
/// </summary>
|
||||
ConnectionClosed = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The Agent wrote the closing <c>--boundary--</c> delimiter, ending the multipart response
|
||||
/// deliberately. Usually means the request was bounded (an Agent honouring a <c>count</c>
|
||||
/// that exhausted, or an administrative stop) rather than a fault. Also reconnectable, but
|
||||
/// worth distinguishing in logs: a stream that keeps closing cleanly points at the request's
|
||||
/// parameters, not at the network.
|
||||
/// </summary>
|
||||
ClosingBoundary = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's <c>/sample</c> stream ended without the caller cancelling it. See
|
||||
/// <see cref="MTConnectStreamException"/> for why this is not a normal end of enumeration.
|
||||
/// </summary>
|
||||
public sealed class MTConnectStreamEndedException : MTConnectStreamException
|
||||
{
|
||||
/// <summary>Creates the exception for a stream that ended for <paramref name="reason"/>.</summary>
|
||||
/// <param name="reason">How the stream ended.</param>
|
||||
/// <param name="agentUri">The <c>/sample</c> request URI whose stream ended.</param>
|
||||
/// <param name="chunksDelivered">How many chunks were yielded before the end.</param>
|
||||
public MTConnectStreamEndedException(MTConnectStreamEndReason reason, string agentUri, long chunksDelivered)
|
||||
: base($"MTConnect /sample stream from '{agentUri}' ended after {chunksDelivered} chunk(s) without the caller cancelling it ({Describe(reason)}). The stream is contracted to run until cancelled, so this is reported rather than returned; resume from the last chunk's nextSequence.")
|
||||
{
|
||||
Reason = reason;
|
||||
AgentUri = agentUri;
|
||||
ChunksDelivered = chunksDelivered;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
public MTConnectStreamEndedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
public MTConnectStreamEndedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
public MTConnectStreamEndedException()
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>How the stream ended.</summary>
|
||||
public MTConnectStreamEndReason Reason { get; }
|
||||
|
||||
/// <summary>The <c>/sample</c> request URI whose stream ended.</summary>
|
||||
public string AgentUri { get; }
|
||||
|
||||
/// <summary>How many chunks were yielded before the stream ended.</summary>
|
||||
public long ChunksDelivered { get; }
|
||||
|
||||
private static string Describe(MTConnectStreamEndReason reason) =>
|
||||
reason switch
|
||||
{
|
||||
MTConnectStreamEndReason.ClosingBoundary => "the Agent wrote the closing multipart boundary",
|
||||
_ => "the connection closed mid-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent answered a <c>/sample</c> request with something that cannot be consumed as a
|
||||
/// stream at all — a single non-multipart document, or a <c>multipart/*</c> response with no
|
||||
/// <c>boundary</c> parameter to frame it by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a <b>configuration</b> error, not a transient one, and is deliberately typed apart
|
||||
/// from <see cref="MTConnectStreamEndedException"/>: reconnecting will produce the identical
|
||||
/// response forever. The usual cause is an endpoint that is not an MTConnect Agent (a reverse
|
||||
/// proxy, a load balancer's health page, or an Agent fronted by something that buffers and
|
||||
/// collapses <c>multipart/x-mixed-replace</c>). Reported instead of yielding the one document
|
||||
/// the Agent did return, because a single snapshot followed by a healthy-looking finished
|
||||
/// stream is exactly how a dead data path disguises itself as a working one.
|
||||
/// </remarks>
|
||||
public sealed class MTConnectStreamNotSupportedException : MTConnectStreamException
|
||||
{
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
public MTConnectStreamNotSupportedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
public MTConnectStreamNotSupportedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
public MTConnectStreamNotSupportedException()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,12 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private const string ConditionContainer = "Condition";
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "streams response";
|
||||
|
||||
/// <summary>How those messages name the header element.</summary>
|
||||
private const string HeaderOwner = "the <Header>";
|
||||
|
||||
/// <summary>
|
||||
/// The three elements whose element children are observations. A ComponentStream may carry
|
||||
/// any subset of them, including none.
|
||||
@@ -105,7 +111,7 @@ internal static class MTConnectStreamsParser
|
||||
{
|
||||
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
|
||||
// trailing line break; XmlReader rejects either ahead of the XML declaration.
|
||||
document = XDocument.Parse(xml.Trim('', ' ', '\t', '\r', '\n'));
|
||||
document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
@@ -140,19 +146,19 @@ internal static class MTConnectStreamsParser
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect streams response has no root element.");
|
||||
|
||||
if (!IsNamed(root, "MTConnectStreams"))
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
|
||||
{
|
||||
throw new InvalidDataException(DescribeUnexpectedRoot(root));
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
|
||||
}
|
||||
|
||||
var header = ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
||||
|
||||
return new MTConnectStreamsResult(
|
||||
RequiredLongAttribute(header, "instanceId"),
|
||||
RequiredLongAttribute(header, "nextSequence"),
|
||||
RequiredLongAttribute(header, "firstSequence"),
|
||||
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
|
||||
ReadObservations(root));
|
||||
}
|
||||
|
||||
@@ -168,7 +174,7 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
foreach (var container in root.Descendants().Where(IsObservationContainer))
|
||||
{
|
||||
var isCondition = IsNamed(container, ConditionContainer);
|
||||
var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
|
||||
foreach (var element in container.Elements())
|
||||
{
|
||||
observations.Add(ReadObservation(element, isCondition));
|
||||
@@ -180,12 +186,20 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
|
||||
{
|
||||
var dataItemId = RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>");
|
||||
var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
|
||||
|
||||
return new MTConnectObservation(
|
||||
dataItemId,
|
||||
isCondition ? ConditionState(element) : element.Value.Trim(),
|
||||
ReadTimestampUtc(element, dataItemId));
|
||||
ReadTimestampUtc(element, dataItemId),
|
||||
|
||||
// A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
|
||||
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
|
||||
// because this is the only layer that can still see the distinction — see
|
||||
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
|
||||
// the element NAME, so child content cannot corrupt it, and flagging one would throw
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && element.HasElements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,7 +217,7 @@ internal static class MTConnectStreamsParser
|
||||
|
||||
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
|
||||
{
|
||||
var raw = RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'");
|
||||
var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
raw,
|
||||
@@ -221,76 +235,6 @@ internal static class MTConnectStreamsParser
|
||||
}
|
||||
|
||||
private static bool IsObservationContainer(XElement element) =>
|
||||
ObservationContainers.Any(name => IsNamed(element, name));
|
||||
ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
|
||||
|
||||
private static bool IsNamed(XElement element, string localName) =>
|
||||
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
|
||||
|
||||
private static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
|
||||
parent.Elements().Where(child => IsNamed(child, localName));
|
||||
|
||||
private static string RequiredAttribute(XElement element, string name, string owner)
|
||||
{
|
||||
var value = OptionalAttribute(element, name);
|
||||
|
||||
return value ?? throw new InvalidDataException(
|
||||
$"MTConnect streams response is malformed: {owner} has no '{name}' attribute.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// Only unqualified attributes are considered, so a namespace-prefixed vendor attribute can
|
||||
/// never be mistaken for an observation's identity or timestamp.
|
||||
/// </summary>
|
||||
private static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static long RequiredLongAttribute(XElement header, string name)
|
||||
{
|
||||
var raw = RequiredAttribute(header, name, "The <Header>");
|
||||
|
||||
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect streams response is malformed: the <Header> has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message for a root element that is not <c>MTConnectStreams</c>, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document (which
|
||||
/// Agents return under HTTP 200 for, e.g., a <c>from</c> outside the buffer).
|
||||
/// </summary>
|
||||
private static string DescribeUnexpectedRoot(XElement root)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect streams response root element is <{root.Name.LocalName}>, expected <MTConnectStreams>.";
|
||||
|
||||
if (!IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
{
|
||||
var code = OptionalAttribute(e, "errorCode");
|
||||
var text = e.Value.Trim();
|
||||
|
||||
return code is null ? text : $"{code}: {text}";
|
||||
})
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The element/attribute reading rules shared by <see cref="MTConnectProbeParser"/> and
|
||||
/// <see cref="MTConnectStreamsParser"/>. Both documents are the same dialect of XML and must be
|
||||
/// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the
|
||||
/// two rules below, either of which fails silently rather than loudly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Rule 1: elements are matched on <see cref="XName.LocalName"/>, ignoring the
|
||||
/// namespace.</b> The namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>), so a fully-qualified match
|
||||
/// would hard-code a version. It also silently drops vendor-extension elements declared in
|
||||
/// their own namespace, whose standard children still inherit the default MTConnect
|
||||
/// namespace — the browse tree simply comes back short, with no error anywhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Rule 2: attributes are read <i>unqualified</i>.</b> A prefixed attribute
|
||||
/// (<c>xsi:type</c> on a document root, a vendor's <c>x:dataItemId</c>) must never be
|
||||
/// mistaken for the real <c>type</c> / <c>dataItemId</c>, which would invent a data item the
|
||||
/// probe never declared.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectXml
|
||||
{
|
||||
/// <summary>Does <paramref name="element"/> have the given local name, whatever its namespace?</summary>
|
||||
/// <param name="element">The element to test.</param>
|
||||
/// <param name="localName">The unqualified name to match.</param>
|
||||
public static bool IsNamed(XElement element, string localName) =>
|
||||
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Direct element children of <paramref name="parent"/> with the given local name.</summary>
|
||||
/// <param name="parent">The element whose children to filter.</param>
|
||||
/// <param name="localName">The unqualified name to match.</param>
|
||||
public static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
|
||||
parent.Elements().Where(child => IsNamed(child, localName));
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// See the type-level remarks for why only unqualified attributes are considered.
|
||||
/// </summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
public static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
/// <summary>Reads a required unqualified attribute, or throws naming the owner and the attribute.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message (e.g. "/probe response").</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is absent or blank.</exception>
|
||||
public static string RequiredAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var value = OptionalAttribute(element, name);
|
||||
|
||||
return value ?? throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has no '{name}' attribute.");
|
||||
}
|
||||
|
||||
/// <summary>Reads an optional unqualified attribute as an <see cref="int"/>.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is present but not an integer.</exception>
|
||||
public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var raw = OptionalAttribute(element, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>Reads a required unqualified attribute as a <see cref="long"/>.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is absent, blank, or not an integer.</exception>
|
||||
public static long RequiredLongAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var raw = RequiredAttribute(element, name, owner, subject);
|
||||
|
||||
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the error message for a document whose root is not what was expected, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document — which
|
||||
/// Agents return under <b>HTTP 200</b>, so <c>EnsureSuccessStatusCode</c> never fires and
|
||||
/// this is the only place the operator can learn what the Agent objected to.
|
||||
/// </summary>
|
||||
/// <param name="root">The document's actual root element.</param>
|
||||
/// <param name="expectedRootName">The root element name the caller required.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>.";
|
||||
|
||||
if (!IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
{
|
||||
var code = OptionalAttribute(e, "errorCode");
|
||||
var text = e.Value.Trim();
|
||||
|
||||
return code is null ? text : $"{code}: {text}";
|
||||
})
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
}
|
||||
}
|
||||
+522
-15
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -10,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
/// <summary>
|
||||
/// Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
|
||||
/// turning an <c>MTConnectStreams</c> document into <see cref="MTConnectStreamsResult"/>, the
|
||||
/// ring-buffer sequence-gap signal (<see cref="MTConnectAgentClient.IsSequenceGap"/>), and the
|
||||
/// ring-buffer sequence-gap signal (<see cref="IMTConnectAgentClient.IsSequenceGap"/>), and the
|
||||
/// <c>multipart/x-mixed-replace</c> chunk framing + heartbeat watchdog on the long-poll leg.
|
||||
/// Every test runs against canned fixtures or a stubbed <see cref="HttpMessageHandler"/> — no
|
||||
/// socket is opened anywhere in this file.
|
||||
@@ -179,7 +181,7 @@ public sealed class MTConnectStreamsParseTests
|
||||
{
|
||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
|
||||
IMTConnectAgentClient.IsSequenceGap(expectedFrom: 1, chunk).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -188,18 +190,18 @@ public sealed class MTConnectStreamsParseTests
|
||||
// The falsifiability leg: a helper hard-wired to true would pass the gap test alone.
|
||||
var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 108, contiguous).ShouldBeFalse();
|
||||
IMTConnectAgentClient.IsSequenceGap(expectedFrom: 108, contiguous).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4999L, true)] // requested older than the buffer holds — data was lost
|
||||
[InlineData(4999L, true)] // expected older than the buffer holds — data was lost
|
||||
[InlineData(5000L, false)] // exactly the oldest retained sequence — nothing was lost
|
||||
[InlineData(5001L, false)] // the agent simply had nothing older to send
|
||||
public void Sequence_gap_is_strictly_firstSequence_greater_than_requested_from(long requestedFrom, bool expected)
|
||||
public void Sequence_gap_is_strictly_firstSequence_greater_than_expected_from(long expectedFrom, bool expected)
|
||||
{
|
||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom, chunk).ShouldBe(expected);
|
||||
IMTConnectAgentClient.IsSequenceGap(expectedFrom, chunk).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- legal but degenerate documents
|
||||
@@ -499,11 +501,17 @@ public sealed class MTConnectStreamsParseTests
|
||||
|
||||
var from = 108L;
|
||||
var gaps = new List<bool>();
|
||||
await foreach (var chunk in client.SampleAsync(from, Ct))
|
||||
await Should.ThrowAsync<MTConnectStreamEndedException>(async () =>
|
||||
{
|
||||
gaps.Add(MTConnectAgentClient.IsSequenceGap(from, chunk));
|
||||
from = chunk.NextSequence;
|
||||
}
|
||||
await foreach (var chunk in client.SampleAsync(from, Ct))
|
||||
{
|
||||
// `from` is the RUNNING cursor, not the original argument — comparing every chunk
|
||||
// against the opening `from` would report a gap on every chunk once the Agent's
|
||||
// buffer rolls past it (I1).
|
||||
gaps.Add(IMTConnectAgentClient.IsSequenceGap(from, chunk));
|
||||
from = chunk.NextSequence;
|
||||
}
|
||||
});
|
||||
|
||||
gaps.ShouldBe([false, true]);
|
||||
from.ShouldBe(5005);
|
||||
@@ -541,7 +549,7 @@ public sealed class MTConnectStreamsParseTests
|
||||
var chunks = await DrainAsync(client.SampleAsync(113, Ct));
|
||||
|
||||
chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty();
|
||||
MTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
|
||||
IMTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
@@ -623,14 +631,107 @@ public sealed class MTConnectStreamsParseTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_ends_cleanly_when_the_agent_closes_the_stream()
|
||||
public async Task SampleAsync_reports_the_closing_boundary_rather_than_ending_quietly()
|
||||
{
|
||||
// C1. The seam contracts SampleAsync to run until cancelled, so a pump has no reason to
|
||||
// handle normal completion: returning here would either drop the subscription silently or
|
||||
// hot-loop reconnecting on an enumerator that ends immediately (#485).
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(108, Ct));
|
||||
var chunks = new List<MTConnectStreamsResult>();
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamEndedException>(async () =>
|
||||
{
|
||||
await foreach (var chunk in client.SampleAsync(108, Ct))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
chunks.ShouldHaveSingleItem();
|
||||
ex.Reason.ShouldBe(MTConnectStreamEndReason.ClosingBoundary);
|
||||
ex.ChunksDelivered.ShouldBe(1);
|
||||
ex.AgentUri.ShouldContain("/sample?from=108");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_reports_a_connection_that_drops_mid_stream()
|
||||
{
|
||||
// No closing boundary — the body simply stops. Distinguished from a deliberate close so an
|
||||
// operator can tell a flaky network from an Agent honouring a bounded request.
|
||||
var truncated = StubHandler.MultipartBody(
|
||||
"bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
var handler = StubHandler.RespondingWithBody("bnd", truncated);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamEndedException>(
|
||||
async () => await EnumerateAsync(client.SampleAsync(108, Ct)));
|
||||
|
||||
ex.Reason.ShouldBe(MTConnectStreamEndReason.ConnectionClosed);
|
||||
ex.ChunksDelivered.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_reports_a_non_multipart_answer_as_a_configuration_error()
|
||||
{
|
||||
// The worst C1 shape: the Agent ignored the streaming request entirely. Yielding the one
|
||||
// snapshot it did return and finishing would report a dead subscription as a healthy one.
|
||||
var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(
|
||||
async () => await EnumerateAsync(client.SampleAsync(108, Ct)));
|
||||
|
||||
ex.Message.ShouldContain("multipart/x-mixed-replace");
|
||||
ex.Message.ShouldContain("text/xml");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_nothing_at_all_from_a_non_multipart_answer()
|
||||
{
|
||||
// Explicitly pinned: the parsed document must NOT reach the caller. One snapshot plus a
|
||||
// finished stream is precisely how a dead data path disguises itself as a working one.
|
||||
var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = new List<MTConnectStreamsResult>();
|
||||
await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
|
||||
{
|
||||
await foreach (var chunk in client.SampleAsync(108, Ct))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
chunks.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_fails_fast_when_a_multipart_response_carries_no_boundary_parameter()
|
||||
{
|
||||
// M2. Falling through to whole-body reading would surface only as a watchdog TimeoutException
|
||||
// much later, pointing an operator at the network instead of at the malformed header.
|
||||
var handler = StubHandler.RespondingWithBoundarylessMultipart();
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(
|
||||
async () => await EnumerateAsync(client.SampleAsync(1, Ct)));
|
||||
|
||||
ex.Message.ShouldContain("boundary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stream_end_and_a_stream_misconfiguration_share_one_catchable_base_type()
|
||||
{
|
||||
// A pump that only wants "the stream is over" should not have to name both.
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ended = await Should.ThrowAsync<MTConnectStreamEndedException>(
|
||||
async () => await EnumerateAsync(client.SampleAsync(108, Ct)));
|
||||
|
||||
ended.ShouldBeAssignableTo<MTConnectStreamException>();
|
||||
typeof(MTConnectStreamException).IsAssignableFrom(typeof(MTConnectStreamNotSupportedException)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -644,6 +745,255 @@ public sealed class MTConnectStreamsParseTests
|
||||
handler.RequestCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------- I2: framing when the transport splits every read
|
||||
|
||||
/// <summary>
|
||||
/// Serving the whole body from one buffer completes every frame in a single
|
||||
/// <c>ReadAsync</c>, so the split-boundary path, the split-header path and the multi-fill
|
||||
/// loop are never entered — on the one behaviour whose headline risk is framing. These drive
|
||||
/// the same fixtures through a transport that hands back only N bytes at a time; at N = 1
|
||||
/// every delimiter, every header line and every document is split maximally.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(7)]
|
||||
public async Task SampleAsync_frames_correctly_when_the_transport_splits_every_boundary_and_header(int bytesPerRead)
|
||||
{
|
||||
var handler = StubHandler.RespondingWithChunkedMultipart(
|
||||
"--------bnd",
|
||||
bytesPerRead,
|
||||
withContentLength: true,
|
||||
await File.ReadAllTextAsync(SampleFixture, Ct),
|
||||
await File.ReadAllTextAsync(GapFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]);
|
||||
chunks.Select(c => c.NextSequence).ShouldBe([113L, 5005L]);
|
||||
chunks[0].Observations.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(7)]
|
||||
public async Task SampleAsync_boundary_scan_framing_survives_a_split_transport_too(int bytesPerRead)
|
||||
{
|
||||
var handler = StubHandler.RespondingWithChunkedMultipart(
|
||||
"bnd",
|
||||
bytesPerRead,
|
||||
withContentLength: false,
|
||||
await File.ReadAllTextAsync(SampleFixture, Ct),
|
||||
await File.ReadAllTextAsync(GapFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_grows_its_buffer_for_a_part_larger_than_the_initial_read_buffer()
|
||||
{
|
||||
// Forces Array.Resize + a Consume across the grown buffer, which a 2 KB fixture never does.
|
||||
var large = LargeStreamsDocument(observationCount: 900);
|
||||
Encoding.UTF8.GetByteCount(large).ShouldBeGreaterThan(16 * 1024);
|
||||
|
||||
var handler = StubHandler.RespondingWithChunkedMultipart(
|
||||
"bnd", bytesPerRead: 1024, withContentLength: true, large, large);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(1, Ct));
|
||||
|
||||
chunks.Count.ShouldBe(2);
|
||||
chunks[0].Observations.Count.ShouldBe(900);
|
||||
chunks[1].Observations.Count.ShouldBe(900);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- I3: disposal during a live enumeration
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
public async Task Disposing_the_client_mid_enumeration_surfaces_as_cancellation()
|
||||
{
|
||||
// Task 9 re-initializes by disposing and rebuilding this client, which can happen while a
|
||||
// pump is still enumerating. An unclassified ObjectDisposedException/IOException from the
|
||||
// socket is something no caller is written to expect.
|
||||
var prefix = StubHandler.MultipartBody(
|
||||
"bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
var handler = StubHandler.RespondingWithStallingStream("bnd", prefix);
|
||||
var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
HeartbeatMs = 20_000
|
||||
});
|
||||
|
||||
await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct);
|
||||
(await chunks.MoveNextAsync()).ShouldBeTrue();
|
||||
|
||||
var pending = chunks.MoveNextAsync().AsTask();
|
||||
client.Dispose();
|
||||
|
||||
var ex = await Should.ThrowAsync<OperationCanceledException>(async () => await pending);
|
||||
ex.ShouldNotBeOfType<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Using_a_disposed_client_throws_object_disposed_rather_than_dialling()
|
||||
{
|
||||
var handler = StubHandler.Hanging();
|
||||
var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
client.Dispose();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(async () => await client.CurrentAsync(Ct));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await EnumerateAsync(client.SampleAsync(1, Ct)));
|
||||
handler.RequestCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_is_idempotent()
|
||||
{
|
||||
var client = NewClient(StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
client.Dispose();
|
||||
|
||||
Should.NotThrow(client.Dispose);
|
||||
}
|
||||
|
||||
// ------------------------------------------- I4: the degraded framing mode is operator-visible
|
||||
|
||||
[Fact]
|
||||
public async Task A_part_without_content_length_warns_once_that_framing_is_degraded()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var handler = StubHandler.RespondingWithMultipart(
|
||||
"bnd",
|
||||
withContentLength: false,
|
||||
await File.ReadAllTextAsync(SampleFixture, Ct),
|
||||
await File.ReadAllTextAsync(GapFixture, Ct));
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger);
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
// Once, not once per part — a per-chunk warning on a long poll is its own outage.
|
||||
logger.Warnings.Count.ShouldBe(1);
|
||||
logger.Warnings[0].ShouldContain("Content-length");
|
||||
logger.Warnings[0].ShouldContain("http://agent:5000/sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_normal_content_length_path_warns_about_nothing()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger);
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
logger.Warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------- I5: operator-authorable options that reach the wire
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Ctor_rejects_a_non_positive_heartbeat(int heartbeatMs)
|
||||
{
|
||||
// heartbeat=0 goes on the query string, and an Agent answers it with an MTConnectError
|
||||
// under HTTP 200 — surfacing as a parse failure that never names the offending key.
|
||||
var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000", HeartbeatMs = heartbeatMs }));
|
||||
|
||||
ex.Message.ShouldContain(nameof(MTConnectDriverOptions.HeartbeatMs));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Ctor_rejects_a_non_positive_sample_interval(int sampleIntervalMs)
|
||||
{
|
||||
var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleIntervalMs = sampleIntervalMs }));
|
||||
|
||||
ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleIntervalMs));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Ctor_rejects_a_non_positive_sample_count(int sampleCount)
|
||||
{
|
||||
var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleCount = sampleCount }));
|
||||
|
||||
ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleCount));
|
||||
}
|
||||
|
||||
// ------------------------------------------- structured (DATA_SET / TABLE) observation signal
|
||||
|
||||
[Fact]
|
||||
public void An_observation_whose_content_is_child_entries_is_flagged_structured()
|
||||
{
|
||||
// <Entry> children concatenate to "12" through element.Value — keys gone, values run
|
||||
// together. The index codes this BadNotSupported rather than publishing noise as Good.
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""
|
||||
<Events><PathFeedrateOverride dataItemId="ds" timestamp="2026-07-24T12:00:00Z" count="2">
|
||||
<Entry key="V1">1</Entry><Entry key="V2">2</Entry>
|
||||
</PathFeedrateOverride></Events>
|
||||
"""));
|
||||
|
||||
var observation = result.Observations.ShouldHaveSingleItem();
|
||||
observation.IsStructured.ShouldBeTrue();
|
||||
observation.DataItemId.ShouldBe("ds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_plain_text_observation_containing_spaces_is_NOT_flagged_structured()
|
||||
{
|
||||
// The load-bearing case: this proves the signal is "the element had element children", not
|
||||
// a whitespace heuristic. A Message reading "Coolant level low" is perfectly valid data and
|
||||
// is textually indistinguishable from concatenated entries.
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""<Events><Message dataItemId="msg" timestamp="2026-07-24T12:00:00Z">Coolant level low</Message></Events>"""));
|
||||
|
||||
var observation = result.Observations.ShouldHaveSingleItem();
|
||||
observation.IsStructured.ShouldBeFalse();
|
||||
observation.Value.ShouldBe("Coolant level low");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_observation_in_the_committed_fixtures_is_unstructured()
|
||||
{
|
||||
// Includes the space-separated TIME_SERIES vector, which is text and must stay unflagged.
|
||||
var current = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
|
||||
|
||||
current.Observations.ShouldAllBe(o => !o.IsStructured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_condition_is_never_flagged_structured_even_with_child_content()
|
||||
{
|
||||
// A condition's value comes from the element NAME, so child content cannot corrupt it.
|
||||
// Flagging one would throw away a perfectly well-determined state.
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""
|
||||
<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" type="SYSTEM">
|
||||
<Detail>vendor extension</Detail>
|
||||
</Fault></Condition>
|
||||
"""));
|
||||
|
||||
var observation = result.Observations.ShouldHaveSingleItem();
|
||||
observation.IsStructured.ShouldBeFalse();
|
||||
observation.Value.ShouldBe("Fault");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ helpers
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
@@ -654,14 +1004,51 @@ public sealed class MTConnectStreamsParseTests
|
||||
private static async Task<List<MTConnectStreamsResult>> DrainAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
|
||||
{
|
||||
var results = new List<MTConnectStreamsResult>();
|
||||
await foreach (var item in source)
|
||||
try
|
||||
{
|
||||
results.Add(item);
|
||||
await foreach (var item in source)
|
||||
{
|
||||
results.Add(item);
|
||||
}
|
||||
}
|
||||
catch (MTConnectStreamEndedException)
|
||||
{
|
||||
// Expected under the C1 contract: every non-cancellation end of a /sample stream is
|
||||
// loud. Tests that care about HOW it ended assert on the exception directly.
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Enumerates to completion, discarding chunks and letting every exception escape.</summary>
|
||||
private static async Task EnumerateAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
|
||||
{
|
||||
await foreach (var _ in source)
|
||||
{
|
||||
// Drained for effect only.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A streams document big enough to force the frame reader to grow its buffer.</summary>
|
||||
private static string LargeStreamsDocument(int observationCount)
|
||||
{
|
||||
var samples = new StringBuilder();
|
||||
for (var i = 0; i < observationCount; i++)
|
||||
{
|
||||
samples.Append(CultureInfo.InvariantCulture,
|
||||
$"""<Position dataItemId="d{i}" timestamp="2026-07-24T12:00:00Z" sequence="{i + 1}">{i}.5</Position>""");
|
||||
}
|
||||
|
||||
return $"""
|
||||
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
|
||||
<Header instanceId="1655000000" firstSequence="1" lastSequence="{observationCount}" nextSequence="{observationCount + 1}"/>
|
||||
<Streams><DeviceStream name="D" uuid="d"><ComponentStream component="Path" componentId="p">
|
||||
<Samples>{samples}</Samples>
|
||||
</ComponentStream></DeviceStream></Streams>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
}
|
||||
|
||||
private static Dictionary<string, MTConnectObservation> ParseCurrent() => ParseFixture(CurrentFixture);
|
||||
|
||||
private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
|
||||
@@ -717,6 +1104,35 @@ public sealed class MTConnectStreamsParseTests
|
||||
public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) =>
|
||||
new(() => Multipart(boundary, new StallingContent(prefix)), hang: false);
|
||||
|
||||
/// <summary>Serves an already-built multipart body verbatim (e.g. one with no closing delimiter).</summary>
|
||||
public static StubHandler RespondingWithBody(string boundary, byte[] body) =>
|
||||
new(() => Multipart(boundary, new ByteArrayContent(body)), hang: false);
|
||||
|
||||
/// <summary>
|
||||
/// Serves a multipart body through a transport that hands back at most
|
||||
/// <paramref name="bytesPerRead"/> bytes per <c>ReadAsync</c>, so boundaries, part
|
||||
/// headers and documents are all split across reads.
|
||||
/// </summary>
|
||||
public static StubHandler RespondingWithChunkedMultipart(
|
||||
string boundary, int bytesPerRead, bool withContentLength, params string[] parts)
|
||||
{
|
||||
var body = MultipartBody(boundary, withContentLength, closing: true, parts);
|
||||
|
||||
return new StubHandler(() => Multipart(boundary, new ChunkedContent(body, bytesPerRead)), hang: false);
|
||||
}
|
||||
|
||||
/// <summary>A response that claims multipart but omits the <c>boundary</c> parameter.</summary>
|
||||
public static StubHandler RespondingWithBoundarylessMultipart() =>
|
||||
new(
|
||||
() =>
|
||||
{
|
||||
var content = new ByteArrayContent([]);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("multipart/x-mixed-replace");
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
|
||||
},
|
||||
hang: false);
|
||||
|
||||
/// <summary>Builds a <c>multipart/x-mixed-replace</c> body exactly as an Agent writes one.</summary>
|
||||
public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts)
|
||||
{
|
||||
@@ -769,6 +1185,97 @@ public sealed class MTConnectStreamsParseTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hands back at most <c>bytesPerRead</c> bytes per read — the split-transport double.</summary>
|
||||
private sealed class ChunkedContent(byte[] body, int bytesPerRead) : HttpContent
|
||||
{
|
||||
protected override Task<Stream> CreateContentReadStreamAsync() =>
|
||||
Task.FromResult<Stream>(new ChunkedStream(body, bytesPerRead));
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
stream.WriteAsync(body, 0, body.Length);
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = body.Length;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ChunkedStream(byte[] body, int bytesPerRead) : Stream
|
||||
{
|
||||
private int _position;
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => body.Length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_position >= body.Length)
|
||||
{
|
||||
return ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
var count = Math.Min(Math.Min(bytesPerRead, buffer.Length), body.Length - _position);
|
||||
body.AsMemory(_position, count).CopyTo(buffer);
|
||||
_position += count;
|
||||
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Captures warning-level log lines so the degraded-framing notice can be asserted.</summary>
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<string> Warnings { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning)
|
||||
{
|
||||
Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves <paramref name="prefix"/>, then never completes another read.</summary>
|
||||
private sealed class StallingContent(byte[] prefix) : HttpContent
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user