fix(mtconnect): make a /sample stream end loud, and correct the gap contract (Task 7 review)

Code review of c46540ae approved the framing algorithm but moved the risk
onto the contract around it.

C1 (critical). SampleAsync returned normally on three different events:
EOF, the closing --boundary--, and a response that was not multipart at
all (where it yielded one parsed snapshot and finished). The seam
documents the stream as yielding until ct is cancelled, so a Task 11 pump
written to that contract would silently drop its subscription or hot-loop
reconnecting; the non-multipart leg reported a configuration error as a
healthy finished stream, which is the #485 quiet-successful-termination
shape aimed at the very next task. Every non-cancellation end now throws:
MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary -
transient) or MTConnectStreamNotSupportedException (configuration -
reconnecting reproduces it forever), sharing one catchable base. The
non-multipart body is still parsed first so an Agent's own MTConnectError
text wins, but the document is NOT yielded.

I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first
parameter is now expectedFrom. The old 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" argument reports a
gap on every chunk once the ring buffer rolls, i.e. an endless /current
re-baseline storm. Both doc blocks also now record that cppagent answers
from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200,
which surfaces as InvalidDataException and NOT as a gap - so Task 11 must
treat that parse failure as a re-baseline trigger too.

I2. 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. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the
fixtures through a split transport, plus a >16 KB document that forces a
buffer resize. Falsifiability: removing either scan overlap, or
collapsing the fill loop, fails ONLY the new chunked tests.

I3. Disposal mid-enumeration surfaces as OperationCanceledException via
an internal dispose-linked token, not a raw ObjectDisposedException that
Task 9's re-init would inflict on an enumerating pump.

I5. 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 names no config key.

I4/M2. The no-Content-length framing fallback logs a one-shot warning
(the client takes an optional ILogger); a multipart response with no
boundary parameter fails fast instead of degrading into a watchdog
timeout.

M5/M6. Shared element/attribute reading rules extracted to MTConnectXml;
the probe parser documents why it alone does not trim BOM/whitespace. The
literal U+FEFF in source became a '' escape.

Also, from Task 8: MTConnectObservation gained IsStructured (default
false), set from element.HasElements. A DATA_SET/TABLE observation's
<Entry> children concatenate through element.Value to nonsense ("12" for
two entries) that the index would publish as Good, and only the parser
can tell that apart from a legitimate space-bearing Message. False for
CONDITION observations - their value comes from the element name, so
child content cannot corrupt it.

275/275 green in this suite's own files.
This commit is contained in:
Joseph Doherty
2026-07-24 15:01:31 -04:00
parent 2399d075e9
commit 712635ce8c
9 changed files with 1232 additions and 294 deletions
@@ -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>&lt;Entry key="…"&gt;</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>&lt;Component&gt;</c> element — so <i>every</i> element child of
/// <c>&lt;Components&gt;</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)}";
}
}