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)"
|
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`
|
## 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;
|
/// <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
|
/// 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
|
/// 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>
|
/// </remarks>
|
||||||
public interface IMTConnectAgentClient
|
public interface IMTConnectAgentClient
|
||||||
{
|
{
|
||||||
@@ -38,13 +40,73 @@ public interface IMTConnectAgentClient
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens an Agent <c>/sample</c> long-poll stream starting at sequence <paramref name="from"/>
|
/// 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,
|
/// 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"/>.
|
|
||||||
/// </summary>
|
/// </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="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);
|
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.Globalization;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
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
|
/// 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
|
/// 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
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
|
/// <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.
|
/// and the watchdog is what closes it here.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A non-positive <c>RequestTimeoutMs</c> is rejected at construction so an
|
/// <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
|
||||||
/// operator-authored <c>0</c> can never come to mean "wait forever" (arch-review 01/S-6).
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
|
/// 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
|
/// status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
|
||||||
/// that and moves the driver to Faulted.
|
/// <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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly HttpClient _streamHttp;
|
private readonly HttpClient _streamHttp;
|
||||||
|
private readonly CancellationTokenSource _disposeCts = new();
|
||||||
|
private readonly ILogger? _logger;
|
||||||
private readonly Uri _probeUri;
|
private readonly Uri _probeUri;
|
||||||
private readonly Uri _currentUri;
|
private readonly Uri _currentUri;
|
||||||
private readonly string _sampleUriBase;
|
private readonly string _sampleUriBase;
|
||||||
private readonly string _sampleQuerySuffix;
|
private readonly string _sampleQuerySuffix;
|
||||||
private readonly TimeSpan _requestTimeout;
|
private readonly TimeSpan _requestTimeout;
|
||||||
private readonly TimeSpan _streamIdleTimeout;
|
private readonly TimeSpan _streamIdleTimeout;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
/// <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="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="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>
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
public MTConnectAgentClient(MTConnectDriverOptions options)
|
/// Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
|
||||||
: this(options, handler: null)
|
/// <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>
|
/// <summary>
|
||||||
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions)"/>, but sends through
|
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
|
||||||
/// the supplied handler so the request/response round trip can be exercised with no socket.
|
/// sends through the supplied handler so the request/response round trip can be exercised
|
||||||
/// The caller retains ownership of <paramref name="handler"/>.
|
/// with no socket. The caller retains ownership of <paramref name="handler"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler)
|
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
if (options.RequestTimeoutMs <= 0)
|
RequirePositive(
|
||||||
{
|
options.RequestTimeoutMs,
|
||||||
throw new ArgumentOutOfRangeException(
|
nameof(MTConnectDriverOptions.RequestTimeoutMs),
|
||||||
nameof(options),
|
"a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
|
||||||
options.RequestTimeoutMs,
|
RequirePositive(
|
||||||
$"{nameof(MTConnectDriverOptions.RequestTimeoutMs)} must be positive; a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely.");
|
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);
|
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
|
||||||
|
|
||||||
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
|
// 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
|
// 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
|
// Agent and an idle one both emit at least one chunk per heartbeat.
|
||||||
// HeartbeatMs is clamped away rather than rejected — the window then collapses to
|
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
||||||
// RequestTimeoutMs, which is still strictly positive, so the read can never be unbounded.
|
|
||||||
_streamIdleTimeout = TimeSpan.FromMilliseconds(
|
|
||||||
(2L * Math.Max(0, options.HeartbeatMs)) + options.RequestTimeoutMs);
|
|
||||||
|
|
||||||
_probeUri = BuildRequestUri(options, "probe");
|
_probeUri = BuildRequestUri(options, "probe");
|
||||||
_currentUri = BuildRequestUri(options, "current");
|
_currentUri = BuildRequestUri(options, "current");
|
||||||
@@ -114,29 +150,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
_streamHttp.Timeout = Timeout.InfiniteTimeSpan;
|
_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/>
|
/// <inheritdoc/>
|
||||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
// 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.
|
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||||
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
|
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
|
||||||
@@ -149,6 +167,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
await using var body = await response.Content.ReadAsStreamAsync(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,
|
long from,
|
||||||
[EnumeratorCancellation] CancellationToken ct)
|
[EnumeratorCancellation] CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
var uri = BuildSampleUri(from);
|
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);
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
|
||||||
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
|
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
|
||||||
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
|
// 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)
|
if (boundary is null)
|
||||||
{
|
{
|
||||||
// Not a multipart stream: the Agent answered with a single document. This is the shape
|
// Not a multipart stream: the Agent answered with a single document, which means it
|
||||||
// an MTConnectError takes — Agents serve one under HTTP 200 — so the parse below is what
|
// ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
|
||||||
// surfaces the Agent's own error text rather than an empty, successful-looking stream.
|
// exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
|
||||||
yield return MTConnectStreamsParser.Parse(await ReadWholeBodyAsync(body, uri, ct).ConfigureAwait(false));
|
// 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)
|
while (true)
|
||||||
{
|
{
|
||||||
var part = await reader.ReadNextPartAsync(ct).ConfigureAwait(false);
|
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
|
||||||
if (part is null)
|
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
|
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||||
@@ -200,6 +236,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delivered++;
|
||||||
|
|
||||||
yield return MTConnectStreamsParser.Parse(part);
|
yield return MTConnectStreamsParser.Parse(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,8 +245,30 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public void Dispose()
|
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();
|
_http.Dispose();
|
||||||
_streamHttp.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) =>
|
private Uri BuildSampleUri(long from) =>
|
||||||
@@ -217,7 +277,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
|
|
||||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
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);
|
deadline.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
HttpResponseMessage response;
|
HttpResponseMessage response;
|
||||||
@@ -225,11 +286,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
{
|
{
|
||||||
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
|
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.
|
// Neither the caller nor disposal cancelled, so this is our own deadline (or
|
||||||
// Reported as a TimeoutException because a bare "A task was canceled" tells an operator
|
// HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
|
||||||
// nothing about which Agent stopped answering.
|
// canceled" tells an operator nothing about which Agent stopped answering.
|
||||||
throw TimedOut(uri);
|
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.
|
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> SendStreamRequestAsync(
|
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);
|
deadline.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
HttpResponseMessage response;
|
HttpResponseMessage response;
|
||||||
@@ -255,7 +316,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw TimedOut(uri);
|
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>
|
/// <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);
|
deadline.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
using var reader = new StreamReader(body, Encoding.UTF8);
|
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);
|
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw TimedOut(uri);
|
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
|
/// 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.
|
/// Agent answered with a single (non-multipart) document.
|
||||||
/// </summary>
|
/// </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;
|
var contentType = response.Content.Headers.ContentType;
|
||||||
if (contentType?.MediaType is null ||
|
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?
|
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
|
||||||
.Trim('"');
|
.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)
|
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||||
@@ -335,18 +409,22 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
|
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
|
||||||
/// dependency set, and TrakHound's
|
/// dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
|
||||||
/// <c>MTConnectHttpClientStream</c> — the one candidate the plan left open — takes a URL
|
/// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
|
||||||
/// and dials its own socket, so it can neither be fed an existing response stream nor be
|
/// existing response stream nor be exercised without a listener. See the Task 0
|
||||||
/// exercised without a listener. See the Task 0 correction in the plan.
|
/// correction in the plan.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
|
/// <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
|
/// 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
|
/// 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
|
/// 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
|
/// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
|
||||||
/// as the expected path.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
|
/// <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.
|
/// malfunctioning Agent cannot grow it without limit.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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;
|
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 byte[] _buffer = new byte[16 * 1024];
|
||||||
private int _length;
|
private int _length;
|
||||||
private bool _finished;
|
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>
|
/// <summary>
|
||||||
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
|
/// 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>
|
/// </summary>
|
||||||
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
|
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -380,42 +463,65 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
|
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
|
||||||
!await EnsureAsync(2, ct).ConfigureAwait(false))
|
!await EnsureAsync(2, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
_finished = true;
|
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "--boundary--" closes the stream.
|
// "--boundary--" closes the stream.
|
||||||
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
|
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
|
||||||
{
|
{
|
||||||
_finished = true;
|
return Finish(MTConnectStreamEndReason.ClosingBoundary);
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
|
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
|
||||||
if (headerEnd < 0)
|
if (headerEnd < 0)
|
||||||
{
|
{
|
||||||
_finished = true;
|
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
|
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
|
||||||
Consume(headerEnd);
|
Consume(headerEnd);
|
||||||
|
|
||||||
var body = ParseContentLength(headers) is { } declared
|
byte[]? body;
|
||||||
? await ReadByLengthAsync(declared, ct).ConfigureAwait(false)
|
if (ParseContentLength(headers) is { } declared)
|
||||||
: await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
{
|
||||||
|
body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WarnOnceAboutMissingContentLength();
|
||||||
|
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
return body is null ? null : Encoding.UTF8.GetString(body);
|
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)
|
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
|
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
_finished = true;
|
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -431,10 +537,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||||
if (next < 0)
|
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];
|
var tail = _buffer[.._length];
|
||||||
_length = 0;
|
_length = 0;
|
||||||
_finished = true;
|
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||||
|
|
||||||
return tail;
|
return tail;
|
||||||
}
|
}
|
||||||
@@ -477,6 +584,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
var searchedTo = 0;
|
var searchedTo = 0;
|
||||||
while (true)
|
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)));
|
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
|
||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -136,10 +136,33 @@ public sealed record MTConnectStreamsResult(
|
|||||||
/// The observation's Agent-reported timestamp, normalized to UTC (<see cref="DateTime.Kind"/>
|
/// 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.
|
/// is always <see cref="DateTimeKind.Utc"/>). Becomes the OPC UA variable's SourceTimestamp.
|
||||||
/// </param>
|
/// </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(
|
public sealed record MTConnectObservation(
|
||||||
string DataItemId,
|
string DataItemId,
|
||||||
string Value,
|
string Value,
|
||||||
DateTime TimestampUtc);
|
DateTime TimestampUtc,
|
||||||
|
bool IsStructured = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.Xml.Linq;
|
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
|
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||||
/// <c><Components></c> is a component.
|
/// <c><Components></c> is a component.
|
||||||
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
/// (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
|
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
|
||||||
/// matched on <see cref="XName.LocalName"/> and the namespace is ignored entirely — nothing
|
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
|
||||||
/// version-specific is hard-coded, and a vendor-extension component in its own namespace
|
/// (match on local name, read attributes unqualified) are shared verbatim with
|
||||||
/// (whose standard child elements still inherit the default MTConnect namespace) is not
|
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
|
||||||
/// silently dropped. Attributes, by contrast, are read unqualified so a prefixed attribute
|
/// </para>
|
||||||
/// (e.g. <c>xsi:type</c>) can never be mistaken for a DataItem's <c>type</c>.
|
/// <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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
||||||
@@ -54,6 +59,9 @@ internal static class MTConnectProbeParser
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const int MaxComponentDepth = 64;
|
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>
|
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
||||||
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
||||||
/// <exception cref="InvalidDataException">
|
/// <exception cref="InvalidDataException">
|
||||||
@@ -106,12 +114,12 @@ internal static class MTConnectProbeParser
|
|||||||
var root = document.Root
|
var root = document.Root
|
||||||
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
|
?? 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(
|
?? throw new InvalidDataException(
|
||||||
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
|
"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) =>
|
private static MTConnectDevice ReadDevice(XElement element) =>
|
||||||
new(
|
new(
|
||||||
RequiredAttribute(element, "id", "Device"),
|
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
|
||||||
OptionalAttribute(element, "name"),
|
MTConnectXml.OptionalAttribute(element, "name"),
|
||||||
ReadComponents(element, depth: 1),
|
ReadComponents(element, depth: 1),
|
||||||
ReadDataItems(element));
|
ReadDataItems(element));
|
||||||
|
|
||||||
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
|
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
|
||||||
new(
|
new(
|
||||||
RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>"),
|
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
|
||||||
OptionalAttribute(element, "name"),
|
MTConnectXml.OptionalAttribute(element, "name"),
|
||||||
ReadComponents(element, depth + 1),
|
ReadComponents(element, depth + 1),
|
||||||
ReadDataItems(element));
|
ReadDataItems(element));
|
||||||
|
|
||||||
@@ -151,104 +159,31 @@ internal static class MTConnectProbeParser
|
|||||||
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
|
$"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())
|
.SelectMany(components => components.Elements())
|
||||||
.Select(element => ReadComponent(element, depth))
|
.Select(element => ReadComponent(element, depth))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
||||||
ChildrenNamed(container, "DataItems")
|
MTConnectXml.ChildrenNamed(container, "DataItems")
|
||||||
.SelectMany(dataItems => ChildrenNamed(dataItems, "DataItem"))
|
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
|
||||||
.Select(ReadDataItem)
|
.Select(ReadDataItem)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
private static MTConnectDataItem ReadDataItem(XElement element)
|
private static MTConnectDataItem ReadDataItem(XElement element)
|
||||||
{
|
{
|
||||||
var id = RequiredAttribute(element, "id", "DataItem");
|
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
|
||||||
|
|
||||||
return new MTConnectDataItem(
|
return new MTConnectDataItem(
|
||||||
id,
|
id,
|
||||||
OptionalAttribute(element, "name"),
|
MTConnectXml.OptionalAttribute(element, "name"),
|
||||||
RequiredAttribute(element, "category", $"DataItem '{id}'"),
|
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
|
||||||
RequiredAttribute(element, "type", $"DataItem '{id}'"),
|
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
|
||||||
OptionalAttribute(element, "subType"),
|
MTConnectXml.OptionalAttribute(element, "subType"),
|
||||||
OptionalAttribute(element, "units"),
|
MTConnectXml.OptionalAttribute(element, "units"),
|
||||||
OptionalAttribute(element, "representation"),
|
MTConnectXml.OptionalAttribute(element, "representation"),
|
||||||
OptionalIntAttribute(element, "sampleCount", id));
|
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";
|
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>
|
/// <summary>
|
||||||
/// The three elements whose element children are observations. A ComponentStream may carry
|
/// The three elements whose element children are observations. A ComponentStream may carry
|
||||||
/// any subset of them, including none.
|
/// 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
|
// 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.
|
// 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)
|
catch (XmlException ex)
|
||||||
{
|
{
|
||||||
@@ -140,19 +146,19 @@ internal static class MTConnectStreamsParser
|
|||||||
var root = document.Root
|
var root = document.Root
|
||||||
?? throw new InvalidDataException("MTConnect streams response has no root element.");
|
?? 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(
|
?? throw new InvalidDataException(
|
||||||
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
||||||
|
|
||||||
return new MTConnectStreamsResult(
|
return new MTConnectStreamsResult(
|
||||||
RequiredLongAttribute(header, "instanceId"),
|
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
|
||||||
RequiredLongAttribute(header, "nextSequence"),
|
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
|
||||||
RequiredLongAttribute(header, "firstSequence"),
|
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
|
||||||
ReadObservations(root));
|
ReadObservations(root));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +174,7 @@ internal static class MTConnectStreamsParser
|
|||||||
|
|
||||||
foreach (var container in root.Descendants().Where(IsObservationContainer))
|
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())
|
foreach (var element in container.Elements())
|
||||||
{
|
{
|
||||||
observations.Add(ReadObservation(element, isCondition));
|
observations.Add(ReadObservation(element, isCondition));
|
||||||
@@ -180,12 +186,20 @@ internal static class MTConnectStreamsParser
|
|||||||
|
|
||||||
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
|
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(
|
return new MTConnectObservation(
|
||||||
dataItemId,
|
dataItemId,
|
||||||
isCondition ? ConditionState(element) : element.Value.Trim(),
|
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>
|
/// <summary>
|
||||||
@@ -203,7 +217,7 @@ internal static class MTConnectStreamsParser
|
|||||||
|
|
||||||
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
|
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(
|
if (!DateTime.TryParse(
|
||||||
raw,
|
raw,
|
||||||
@@ -221,76 +235,6 @@ internal static class MTConnectStreamsParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsObservationContainer(XElement element) =>
|
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.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
|
/// Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
|
||||||
/// turning an <c>MTConnectStreams</c> document into <see cref="MTConnectStreamsResult"/>, the
|
/// 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.
|
/// <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
|
/// Every test runs against canned fixtures or a stubbed <see cref="HttpMessageHandler"/> — no
|
||||||
/// socket is opened anywhere in this file.
|
/// socket is opened anywhere in this file.
|
||||||
@@ -179,7 +181,7 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
{
|
{
|
||||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||||
|
|
||||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
|
IMTConnectAgentClient.IsSequenceGap(expectedFrom: 1, chunk).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -188,18 +190,18 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
// The falsifiability leg: a helper hard-wired to true would pass the gap test alone.
|
// The falsifiability leg: a helper hard-wired to true would pass the gap test alone.
|
||||||
var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));
|
var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));
|
||||||
|
|
||||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 108, contiguous).ShouldBeFalse();
|
IMTConnectAgentClient.IsSequenceGap(expectedFrom: 108, contiguous).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[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(5000L, false)] // exactly the oldest retained sequence — nothing was lost
|
||||||
[InlineData(5001L, false)] // the agent simply had nothing older to send
|
[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));
|
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||||
|
|
||||||
MTConnectAgentClient.IsSequenceGap(requestedFrom, chunk).ShouldBe(expected);
|
IMTConnectAgentClient.IsSequenceGap(expectedFrom, chunk).ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- legal but degenerate documents
|
// --------------------------------------------------------------- legal but degenerate documents
|
||||||
@@ -499,11 +501,17 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
|
|
||||||
var from = 108L;
|
var from = 108L;
|
||||||
var gaps = new List<bool>();
|
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));
|
await foreach (var chunk in client.SampleAsync(from, Ct))
|
||||||
from = chunk.NextSequence;
|
{
|
||||||
}
|
// `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]);
|
gaps.ShouldBe([false, true]);
|
||||||
from.ShouldBe(5005);
|
from.ShouldBe(5005);
|
||||||
@@ -541,7 +549,7 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
var chunks = await DrainAsync(client.SampleAsync(113, Ct));
|
var chunks = await DrainAsync(client.SampleAsync(113, Ct));
|
||||||
|
|
||||||
chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty();
|
chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty();
|
||||||
MTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
|
IMTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = 30_000)]
|
[Fact(Timeout = 30_000)]
|
||||||
@@ -623,14 +631,107 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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));
|
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
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();
|
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]
|
[Fact]
|
||||||
@@ -644,6 +745,255 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
handler.RequestCount.ShouldBe(0);
|
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
|
// ------------------------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
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)
|
private static async Task<List<MTConnectStreamsResult>> DrainAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
|
||||||
{
|
{
|
||||||
var results = new List<MTConnectStreamsResult>();
|
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;
|
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> ParseCurrent() => ParseFixture(CurrentFixture);
|
||||||
|
|
||||||
private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
|
private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
|
||||||
@@ -717,6 +1104,35 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) =>
|
public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) =>
|
||||||
new(() => Multipart(boundary, new StallingContent(prefix)), hang: false);
|
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>
|
/// <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)
|
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>
|
/// <summary>Serves <paramref name="prefix"/>, then never completes another read.</summary>
|
||||||
private sealed class StallingContent(byte[] prefix) : HttpContent
|
private sealed class StallingContent(byte[] prefix) : HttpContent
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user