feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)
Adds the /current and /sample legs to MTConnectAgentClient: - MTConnectStreamsParser: hand-rolled System.Xml.Linq parse of an MTConnectStreams document into MTConnectStreamsResult. Observations are every element child of <Samples>/<Events>/<Condition>, keyed by dataItemId (the element NAME is the data item's type in PascalCase, so a fixed name set would drop observations). A CONDITION's value is its element name, not its text - <Normal/> is empty - and <Unavailable/> normalizes onto the same UNAVAILABLE sentinel a Sample/Event carries as text, so Task 8's no-comms mapping sees one token. Timestamps are normalized to DateTimeKind.Utc explicitly. - IsSequenceGap(requestedFrom, chunk) => chunk.FirstSequence > requestedFrom, so Task 11's pump can re-baseline after a ring-buffer overflow. Equality is NOT a gap. - SampleAsync: ResponseHeadersRead + a hand-rolled multipart/x-mixed-replace frame reader (Content-length fast path, boundary-scan fallback), bounded by a heartbeat watchdog derived from HeartbeatMs so a silent Agent faults instead of wedging the pump - the S7 frozen-peer shape (arch-review R2-01). The long poll gets its own HttpClient with an infinite Timeout so the unary /probe + /current deadline stays bounded. Task 0 package decision, option (c): both TrakHound PackageReferences and their PackageVersions are dropped. MTConnectHttpClientStream takes a URL and dials its own socket (no handler/stream seam, so it cannot serve a socket-free unit suite), and it emits already-parsed documents through the formatter lookup Task 6 proved is missing - framing-only reuse was never actually on offer. The driver now depends on nothing but the BCL. Recorded in the plan's Task 0 CORRECTION block. Also folds in two Task 6 review carry-overs: the stale IMTConnectAgentClient doc comment claiming a TrakHound-backed implementation, and a probe test constructing a mixed-namespace vendor extension (<x:Pump> with default-namespace <DataItems> children) that backs the parser's ignore-the-namespace claim with a test. 187/187 green.
This commit is contained in:
@@ -84,11 +84,11 @@
|
||||
ships net6.0/net8.0 only.
|
||||
-->
|
||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
||||
<!-- Driver.MTConnect (Task 0, 2026-07-24): verified against real nuget.org — MIT (SPDX license
|
||||
expression), netstandard2.0 lib resolves on net10 via the in-box compat mapping.
|
||||
MTConnect.NET-HTTP pulls MTConnect.NET-TLS 6.9.0.2 transitively; not pinned separately. -->
|
||||
<PackageVersion Include="MTConnect.NET-Common" Version="6.9.0.2" />
|
||||
<PackageVersion Include="MTConnect.NET-HTTP" Version="6.9.0.2" />
|
||||
<!-- Driver.MTConnect carries NO backend NuGet. The TrakHound MTConnect.NET-Common/-HTTP pins
|
||||
Task 0 added were removed in Task 7 (2026-07-24): neither package can parse an MTConnect
|
||||
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
|
||||
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
|
||||
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
|
||||
@@ -96,8 +96,53 @@ owns the final call:** (a) use `-HTTP` for framing only, (b) add `MTConnect.NET-
|
||||
wholesale, or (c) hand-roll the boundary reader and **drop both package references** — in which case the
|
||||
Task-0 rationale comment in `Directory.Packages.props` goes with them. Record the outcome here.
|
||||
|
||||
**OUTCOME (Task 7) — option (c): hand-rolled boundary reader, BOTH package references DROPPED.**
|
||||
`MTConnect.NET-Common`, `MTConnect.NET-HTTP` and the transitive `MTConnect.NET-TLS` are gone from
|
||||
`Directory.Packages.props` and from `Driver.MTConnect.csproj`; the Task-0 rationale comments went with
|
||||
them (each replaced by a comment naming this block). **The driver now depends on nothing but the BCL** —
|
||||
`HttpClient` + `System.Xml.Linq`. Reflection over `MTConnect.NET-HTTP` 6.9.0.2 (net9.0 lib, with
|
||||
`-Common` resolved alongside) settled the one open candidate:
|
||||
|
||||
```
|
||||
TYPE MTConnect.Clients.MTConnectHttpClientStream
|
||||
.ctor(String url, String documentFormat)
|
||||
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
|
||||
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
|
||||
```
|
||||
|
||||
Three independent disqualifiers, any one of which is fatal:
|
||||
|
||||
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
|
||||
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
|
||||
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 6–13 are unit-testable
|
||||
against canned XML with no listener; adopting this type would have forced a live agent (or a real
|
||||
loopback HTTP server) into the unit suite.
|
||||
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
|
||||
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
|
||||
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
|
||||
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
|
||||
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
|
||||
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
|
||||
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
|
||||
|
||||
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
|
||||
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
|
||||
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
|
||||
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
|
||||
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
|
||||
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
|
||||
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
|
||||
|
||||
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
|
||||
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
|
||||
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
|
||||
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
|
||||
watchdog on its body.
|
||||
|
||||
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
|
||||
verification checklist needs one end-to-end call against a real input, not just a restore.
|
||||
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
|
||||
a second: **check the constructor for a test seam before counting a library as usable** — a type that
|
||||
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,8 +9,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// canned probe/current/sample XML, with no sockets involved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The production implementation (Task 6/7) is built on the TrakHound MTConnect.NET client
|
||||
/// libraries (Task 0's decision); a fake for tests only needs to implement these three members.
|
||||
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
|
||||
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
|
||||
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
|
||||
/// <c>MTConnectStreamsParser</c>) plus a <c>multipart/x-mixed-replace</c> frame reader for the
|
||||
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
|
||||
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
|
||||
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
|
||||
/// needs to implement these three members.
|
||||
/// </remarks>
|
||||
public interface IMTConnectAgentClient
|
||||
{
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,19 +12,37 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
|
||||
/// an <see cref="HttpClient"/> (which itself dials nothing until a request is issued), so a
|
||||
/// throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that.
|
||||
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
|
||||
/// so a throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
|
||||
/// nothing until it is enumerated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every call is deadline-bounded.</b> Each request runs under a
|
||||
/// <see cref="CancellationTokenSource"/> linked to the caller's token and cancelled after
|
||||
/// <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, <i>and</i>
|
||||
/// <see cref="HttpClient.Timeout"/> is set to the same bound. A frozen Agent surfaces as a
|
||||
/// <see cref="TimeoutException"/> rather than wedging the caller — the lesson from this
|
||||
/// repo's S7 read-leg wall-clock gap (arch-review R2-01). A non-positive timeout is rejected
|
||||
/// at construction so an operator-authored <c>0</c> can never come to mean "wait forever"
|
||||
/// (arch-review 01/S-6).
|
||||
/// <b>Two <see cref="HttpClient"/>s, and the split is deliberate.</b>
|
||||
/// <see cref="HttpClient.Timeout"/> is a per-instance, whole-response deadline, so a single
|
||||
/// client cannot serve both legs: the unary <c>/probe</c> and <c>/current</c> calls must be
|
||||
/// bounded by <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, while <c>/sample</c> is
|
||||
/// a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
|
||||
/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
|
||||
/// R2-01 says must never happen — so the streaming leg gets its own client with
|
||||
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
|
||||
/// run under a <see cref="CancellationTokenSource"/> linked to the caller's token and
|
||||
/// cancelled after <c>RequestTimeoutMs</c>. The streaming leg bounds its <i>header</i> phase
|
||||
/// the same way (a peer that accepts the connection and never answers must not wedge the
|
||||
/// pump), then hands the body to a <b>heartbeat watchdog</b>: MTConnect Agents emit a
|
||||
/// keep-alive chunk every <see cref="MTConnectDriverOptions.HeartbeatMs"/> precisely so a
|
||||
/// client can tell "quiet but alive" from "dead", so every body read is bounded by a
|
||||
/// two-missed-heartbeat window and a silent Agent surfaces as a
|
||||
/// <see cref="TimeoutException"/>. This is the exact shape of this repo's S7 frozen-peer
|
||||
/// production defect (arch-review R2-01) — an async read that ignores the socket deadline —
|
||||
/// and the watchdog is what closes it here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A non-positive <c>RequestTimeoutMs</c> is rejected at construction so an
|
||||
/// operator-authored <c>0</c> can never come to mean "wait forever" (arch-review 01/S-6).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
|
||||
@@ -31,8 +53,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly HttpClient _streamHttp;
|
||||
private readonly Uri _probeUri;
|
||||
private readonly Uri _currentUri;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
|
||||
/// <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>
|
||||
@@ -61,10 +88,50 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
|
||||
|
||||
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
|
||||
// HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
|
||||
// Agent and an idle one both emit at least one chunk per heartbeat. A non-positive
|
||||
// HeartbeatMs is clamped away rather than rejected — the window then collapses to
|
||||
// RequestTimeoutMs, which is still strictly positive, so the read can never be unbounded.
|
||||
_streamIdleTimeout = TimeSpan.FromMilliseconds(
|
||||
(2L * Math.Max(0, options.HeartbeatMs)) + options.RequestTimeoutMs);
|
||||
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
||||
_sampleQuerySuffix = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
|
||||
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
_http.Timeout = _requestTimeout;
|
||||
|
||||
_streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
|
||||
// See the class remarks: a long poll must outlive the unary deadline by design, and the
|
||||
// heartbeat watchdog — not this property — is what keeps it bounded.
|
||||
_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/>
|
||||
@@ -80,18 +147,73 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct) =>
|
||||
// Task 7 adds the /current leg (MTConnectStreamsParser); deliberately not stubbed as an
|
||||
// empty result, which would read as a working call that silently returns nothing.
|
||||
throw new NotImplementedException("The MTConnect /current leg lands in Task 7.");
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return MTConnectStreamsParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct) =>
|
||||
// Task 7 adds the /sample long-poll leg (multipart chunk framing + sequence-gap detection).
|
||||
throw new NotImplementedException("The MTConnect /sample leg lands in Task 7.");
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
var uri = BuildSampleUri(from);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
|
||||
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
|
||||
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
|
||||
using var response = await SendStreamRequestAsync(request, uri, ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var boundary = ResolveMultipartBoundary(response);
|
||||
if (boundary is null)
|
||||
{
|
||||
// Not a multipart stream: the Agent answered with a single document. This is the shape
|
||||
// an MTConnectError takes — Agents serve one under HTTP 200 — so the parse below is what
|
||||
// surfaces the Agent's own error text rather than an empty, successful-looking stream.
|
||||
yield return MTConnectStreamsParser.Parse(await ReadWholeBodyAsync(body, uri, ct).ConfigureAwait(false));
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri);
|
||||
while (true)
|
||||
{
|
||||
var part = await reader.ReadNextPartAsync(ct).ConfigureAwait(false);
|
||||
if (part is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
// Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
|
||||
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
|
||||
// caller's `from` behind the Agent's buffer.
|
||||
if (string.IsNullOrWhiteSpace(part))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return MTConnectStreamsParser.Parse(part);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => _http.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_streamHttp.Dispose();
|
||||
}
|
||||
|
||||
private Uri BuildSampleUri(long from) =>
|
||||
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
|
||||
UriKind.Absolute);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
@@ -108,8 +230,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// The caller did not cancel, so this is our own deadline (or HttpClient.Timeout) firing.
|
||||
// Reported as a TimeoutException because a bare "A task was canceled" tells an operator
|
||||
// nothing about which Agent stopped answering.
|
||||
throw new TimeoutException(
|
||||
$"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -117,6 +238,73 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues the <c>/sample</c> request and returns as soon as the response headers land. Only
|
||||
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendStreamRequestAsync(
|
||||
HttpRequestMessage request, Uri uri, CancellationToken ct)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _streamHttp
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
|
||||
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationToken ct)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var reader = new StreamReader(body, Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeoutException TimedOut(Uri uri) =>
|
||||
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
|
||||
/// <summary>
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
/// Agent answered with a single (non-multipart) document.
|
||||
/// </summary>
|
||||
private static string? ResolveMultipartBoundary(HttpResponseMessage response)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType;
|
||||
if (contentType?.MediaType is null ||
|
||||
!contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var boundary = contentType.Parameters
|
||||
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
|
||||
.Trim('"');
|
||||
|
||||
return string.IsNullOrEmpty(boundary) ? null : boundary;
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||
{
|
||||
var agentUri = options.AgentUri?.Trim();
|
||||
@@ -139,4 +327,291 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
|
||||
/// incrementally and never buffering more than the part currently in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
|
||||
/// dependency set, and TrakHound's
|
||||
/// <c>MTConnectHttpClientStream</c> — the one candidate the plan left open — takes a URL
|
||||
/// and dials its own socket, so it can neither be fed an existing response stream nor be
|
||||
/// exercised without a listener. See the Task 0 correction in the plan.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
|
||||
/// a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
|
||||
/// by length and the chunk is emitted the instant it is complete. Without it there is no
|
||||
/// way to know a part has ended except by seeing the <i>next</i> boundary, so that path
|
||||
/// necessarily emits each chunk one chunk late; it exists as a correctness fallback, not
|
||||
/// as the expected path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
|
||||
/// parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
|
||||
/// malfunctioning Agent cannot grow it without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MultipartStreamReader(Stream stream, string boundary, TimeSpan idleTimeout, Uri uri)
|
||||
{
|
||||
private const int MaxBufferBytes = 32 * 1024 * 1024;
|
||||
|
||||
private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
|
||||
private static readonly byte[] LfLf = "\n\n"u8.ToArray();
|
||||
|
||||
private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
|
||||
private byte[] _buffer = new byte[16 * 1024];
|
||||
private int _length;
|
||||
private bool _finished;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
|
||||
/// the Agent closes the connection.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
|
||||
{
|
||||
if (_finished)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
|
||||
!await EnsureAsync(2, ct).ConfigureAwait(false))
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// "--boundary--" closes the stream.
|
||||
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
|
||||
if (headerEnd < 0)
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
|
||||
Consume(headerEnd);
|
||||
|
||||
var body = ParseContentLength(headers) is { } declared
|
||||
? await ReadByLengthAsync(declared, ct).ConfigureAwait(false)
|
||||
: await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return body is null ? null : Encoding.UTF8.GetString(body);
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
|
||||
{
|
||||
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
|
||||
{
|
||||
_finished = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = _buffer[..declared];
|
||||
Consume(declared);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadToNextDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||
if (next < 0)
|
||||
{
|
||||
// EOF with no closing delimiter: whatever is buffered is the last part.
|
||||
var tail = _buffer[.._length];
|
||||
_length = 0;
|
||||
_finished = true;
|
||||
|
||||
return tail;
|
||||
}
|
||||
|
||||
var body = _buffer[..TrimTrailingLineBreak(next)];
|
||||
|
||||
// Leave the delimiter itself in place; the next call's skip consumes it.
|
||||
Consume(next);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Strips the single line break that terminates a part body before its delimiter.</summary>
|
||||
private int TrimTrailingLineBreak(int end)
|
||||
{
|
||||
if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
|
||||
{
|
||||
return end - 2;
|
||||
}
|
||||
|
||||
return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
|
||||
}
|
||||
|
||||
/// <summary>Advances past the next boundary delimiter; <c>false</c> at end of stream.</summary>
|
||||
private async Task<bool> SkipThroughDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Consume(index + _delimiter.Length);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> FindDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var searchedTo = 0;
|
||||
while (true)
|
||||
{
|
||||
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
searchedTo = _length;
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the end of the part's header block — the first blank line after the delimiter.
|
||||
/// A part with no headers at all is legal, in which case the blank line sits at offset 0.
|
||||
/// </summary>
|
||||
private async Task<int> FindHeaderEndAsync(CancellationToken ct)
|
||||
{
|
||||
var searchedTo = 0;
|
||||
while (true)
|
||||
{
|
||||
var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
|
||||
var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));
|
||||
|
||||
if (crlf >= 0 && (lf < 0 || crlf <= lf))
|
||||
{
|
||||
return crlf + CrLfCrLf.Length;
|
||||
}
|
||||
|
||||
if (lf >= 0)
|
||||
{
|
||||
return lf + LfLf.Length;
|
||||
}
|
||||
|
||||
searchedTo = _length;
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ParseContentLength(string headers)
|
||||
{
|
||||
foreach (var line in headers.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = trimmed["content-length:".Length..].Trim();
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
|
||||
value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureAsync(int count, CancellationToken ct)
|
||||
{
|
||||
while (_length < count)
|
||||
{
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads at least one more byte, bounded by the heartbeat watchdog. Returns <c>false</c>
|
||||
/// at end of stream and throws <see cref="TimeoutException"/> when the Agent goes silent.
|
||||
/// </summary>
|
||||
private async Task<bool> FillAsync(CancellationToken ct)
|
||||
{
|
||||
if (_length == _buffer.Length)
|
||||
{
|
||||
if (_buffer.Length >= MaxBufferBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
}
|
||||
|
||||
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
|
||||
}
|
||||
|
||||
using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
watchdog.CancelAfter(idleTimeout);
|
||||
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_length += read;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private int IndexOf(byte[] pattern, int start)
|
||||
{
|
||||
if (start >= _length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());
|
||||
|
||||
return index < 0 ? -1 : index + start;
|
||||
}
|
||||
|
||||
private void Consume(int count)
|
||||
{
|
||||
Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
|
||||
_length -= count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> The referenced TrakHound packages
|
||||
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2) contain <b>no</b>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> The TrakHound packages Task 0 pinned
|
||||
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2 — both dropped in Task 7) contain <b>no</b>
|
||||
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
|
||||
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
|
||||
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an MTConnect Agent <c>MTConnectStreams</c> document — a <c>/current</c> snapshot or one
|
||||
/// chunk of a <c>/sample</c> multipart stream — into the neutral
|
||||
/// <see cref="MTConnectStreamsResult"/>. Deliberately a pure, socket-free static so the whole
|
||||
/// parse is unit-testable against canned fixtures; chunk framing lives in
|
||||
/// <see cref="MTConnectAgentClient"/> and hands this parser one chunk's XML at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> Same finding as
|
||||
/// <see cref="MTConnectProbeParser"/>: the packages pinned by Task 0 ship no XML document
|
||||
/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
|
||||
/// package references were dropped in Task 7; the driver depends only on the BCL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The Streams document is shaped nothing like the Devices document</b>, and three
|
||||
/// differences drive this parser's design.
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>The observation element's name is the data item's type in PascalCase</b>
|
||||
/// (<c>Position</c>, <c>PartCount</c>, <c>Execution</c>) — not the probe's
|
||||
/// UPPER_SNAKE <c>POSITION</c> / <c>PART_COUNT</c>. The standard defines hundreds of
|
||||
/// types and vendors add more, so matching a fixed set of element names would drop
|
||||
/// observations silently. <b>Every</b> element child of <c><Samples></c>,
|
||||
/// <c><Events></c> and <c><Condition></c> is therefore an observation,
|
||||
/// keyed by its <c>dataItemId</c> attribute — the only identity the probe model and
|
||||
/// the streams document actually share.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>A CONDITION observation's value is its ELEMENT NAME, not its text.</b>
|
||||
/// <c><Normal/></c> / <c><Warning/></c> / <c><Fault/></c> /
|
||||
/// <c><Unavailable/></c> carry the state in the name and are typically empty;
|
||||
/// where text is present it is the operator-facing message, not the state. Reading
|
||||
/// the text would report every condition as an empty string.
|
||||
/// <c><Unavailable/></c> is normalized to the literal
|
||||
/// <see cref="UnavailableSentinel"/> so a no-comms condition lands on exactly the
|
||||
/// same sentinel as a Sample/Event whose text is <c>UNAVAILABLE</c> — the
|
||||
/// observation index maps that one token to bad quality.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Timestamps are normalized to UTC explicitly.</b> MTConnect timestamps are
|
||||
/// ISO-8601 Zulu, but a bare <c>DateTime.Parse</c> yields <c>Local</c> or
|
||||
/// <c>Unspecified</c> depending on machine settings — and this value becomes the
|
||||
/// OPC UA SourceTimestamp, so a wrong <see cref="DateTimeKind"/> shifts every
|
||||
/// timestamp by the host's UTC offset with nothing to show for it.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> A malformed document, a non-<c>MTConnectStreams</c> root (an Agent
|
||||
/// <c>MTConnectError</c> served under HTTP 200 included), an unusable header, or an
|
||||
/// observation missing its <c>dataItemId</c>/<c>timestamp</c> all throw
|
||||
/// <see cref="InvalidDataException"/>. A document with a valid header and <i>zero</i>
|
||||
/// observations, by contrast, is entirely legal — <c>/sample</c> chunks are deltas and an
|
||||
/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
|
||||
/// observation list rather than throwing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectStreamsParser
|
||||
{
|
||||
/// <summary>
|
||||
/// The single token that means "the Agent has no value for this data item". Sample/Event
|
||||
/// observations carry it as text; a CONDITION carries it as the element name
|
||||
/// <c><Unavailable/></c> and is normalized onto this spelling here.
|
||||
/// </summary>
|
||||
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||
|
||||
private const string ConditionContainer = "Condition";
|
||||
|
||||
/// <summary>
|
||||
/// The three elements whose element children are observations. A ComponentStream may carry
|
||||
/// any subset of them, including none.
|
||||
/// </summary>
|
||||
private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];
|
||||
|
||||
/// <summary>Parses a <c>/current</c> response, or one <c>/sample</c> chunk, held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectStreams</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
/// The payload is empty, not well-formed XML, not an <c>MTConnectStreams</c> document (an
|
||||
/// Agent <c>MTConnectError</c> included — Agents answer a bad request with one under HTTP
|
||||
/// 200), carries no usable <c><Header></c>, or carries a malformed observation.
|
||||
/// </exception>
|
||||
public static MTConnectStreamsResult Parse(string xml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
|
||||
}
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
|
||||
// trailing line break; XmlReader rejects either ahead of the XML declaration.
|
||||
document = XDocument.Parse(xml.Trim('', ' ', '\t', '\r', '\n'));
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
|
||||
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
||||
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
||||
public static MTConnectStreamsResult Parse(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Load(stream);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
private static MTConnectStreamsResult Build(XDocument document)
|
||||
{
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect streams response has no root element.");
|
||||
|
||||
if (!IsNamed(root, "MTConnectStreams"))
|
||||
{
|
||||
throw new InvalidDataException(DescribeUnexpectedRoot(root));
|
||||
}
|
||||
|
||||
var header = ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
||||
|
||||
return new MTConnectStreamsResult(
|
||||
RequiredLongAttribute(header, "instanceId"),
|
||||
RequiredLongAttribute(header, "nextSequence"),
|
||||
RequiredLongAttribute(header, "firstSequence"),
|
||||
ReadObservations(root));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects every observation in the document, in Agent-supplied order. Containers are found
|
||||
/// by descending the whole <c><Streams></c> subtree rather than by walking a fixed
|
||||
/// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
|
||||
/// hide a device's observations.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MTConnectObservation> ReadObservations(XElement root)
|
||||
{
|
||||
var observations = new List<MTConnectObservation>();
|
||||
|
||||
foreach (var container in root.Descendants().Where(IsObservationContainer))
|
||||
{
|
||||
var isCondition = IsNamed(container, ConditionContainer);
|
||||
foreach (var element in container.Elements())
|
||||
{
|
||||
observations.Add(ReadObservation(element, isCondition));
|
||||
}
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
|
||||
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
|
||||
{
|
||||
var dataItemId = RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>");
|
||||
|
||||
return new MTConnectObservation(
|
||||
dataItemId,
|
||||
isCondition ? ConditionState(element) : element.Value.Trim(),
|
||||
ReadTimestampUtc(element, dataItemId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A condition's state is its element name. <c>Unavailable</c> is normalized to the
|
||||
/// <see cref="UnavailableSentinel"/> spelling so downstream quality mapping has exactly one
|
||||
/// token to recognize; every other state is reported as-named (<c>Normal</c>, <c>Warning</c>,
|
||||
/// <c>Fault</c>, and any vendor state).
|
||||
/// </summary>
|
||||
private static string ConditionState(XElement element)
|
||||
{
|
||||
var state = element.Name.LocalName;
|
||||
|
||||
return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
|
||||
}
|
||||
|
||||
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
|
||||
{
|
||||
var raw = RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'");
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
raw,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
|
||||
}
|
||||
|
||||
// AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
|
||||
// quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
|
||||
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static bool IsObservationContainer(XElement element) =>
|
||||
ObservationContainers.Any(name => 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)}";
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -17,13 +17,12 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Task 0 decision (verified 2026-07-24 against real nuget.org): TrakHound MTConnect.NET,
|
||||
MIT, netstandard2.0 lib (resolves on net10 via the in-box compat mapping). Pulls
|
||||
MTConnect.NET-TLS 6.9.0.2 transitively via -HTTP — expected, not a rogue dependency. -->
|
||||
<PackageReference Include="MTConnect.NET-Common"/>
|
||||
<PackageReference Include="MTConnect.NET-HTTP"/>
|
||||
</ItemGroup>
|
||||
<!-- No backend NuGet: the Agent surface is plain HTTP + XML, served entirely by the BCL
|
||||
(HttpClient + System.Xml.Linq). The TrakHound MTConnect.NET-Common/-HTTP references Task 0
|
||||
added were removed in Task 7 — they can neither parse an MTConnect document (no XML
|
||||
formatter ships in either package) nor frame the /sample stream socket-free (their client
|
||||
type dials its own URL). See the Task 0 CORRECTION block in
|
||||
docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests"/>
|
||||
|
||||
+43
-21
@@ -196,6 +196,49 @@ public sealed class MTConnectProbeParseTests
|
||||
device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace()
|
||||
{
|
||||
// The shape the parser's ignore-the-namespace rule exists for, and the reason attributes are
|
||||
// read unqualified: the vendor component carries its OWN namespace prefix, while its
|
||||
// standard <DataItems>/<DataItem> children inherit the DEFAULT MTConnect namespace. Matching
|
||||
// components on a fully-qualified XName would silently drop <x:Pump> and every data item
|
||||
// beneath it, leaving the browse tree short with no error anywhere.
|
||||
const string Xml = """
|
||||
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
|
||||
xmlns:x="urn:vendor:acme:1.0">
|
||||
<Devices>
|
||||
<Device id="d" name="D">
|
||||
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
|
||||
<Components>
|
||||
<x:Pump id="pump1" name="pump">
|
||||
<DataItems>
|
||||
<DataItem id="pump_pressure" category="SAMPLE" type="PRESSURE" units="PASCAL"/>
|
||||
</DataItems>
|
||||
<Components>
|
||||
<x:Impeller id="imp1">
|
||||
<DataItems>
|
||||
<DataItem id="imp_rpm" category="SAMPLE" type="ROTARY_VELOCITY"/>
|
||||
</DataItems>
|
||||
</x:Impeller>
|
||||
</Components>
|
||||
</x:Pump>
|
||||
</Components>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
var device = MTConnectProbeParser.Parse(Xml).Devices[0];
|
||||
|
||||
var pump = device.Components.ShouldHaveSingleItem();
|
||||
pump.Id.ShouldBe("pump1");
|
||||
pump.Name.ShouldBe("pump");
|
||||
pump.Components.ShouldHaveSingleItem().Id.ShouldBe("imp1");
|
||||
device.AllDataItems().Select(d => d.Id).ShouldBe(["d_avail", "pump_pressure", "imp_rpm"]);
|
||||
device.AllDataItems().Single(d => d.Id == "pump_pressure").Units.ShouldBe("PASCAL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_parse_reads_every_device_of_a_multi_device_agent()
|
||||
{
|
||||
@@ -408,27 +451,6 @@ public sealed class MTConnectProbeParseTests
|
||||
handler.RequestCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- scope boundary: Task 7 legs
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_is_not_implemented_until_task_7()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<NotImplementedException>(
|
||||
async () => await client.CurrentAsync(TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Task 7");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleAsync_is_not_implemented_until_task_7()
|
||||
{
|
||||
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = Should.Throw<NotImplementedException>(() => client.SampleAsync(1, CancellationToken.None));
|
||||
ex.Message.ShouldContain("Task 7");
|
||||
}
|
||||
|
||||
private static Task<string> ReadFixtureAsync() =>
|
||||
File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
|
||||
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
|
||||
/// turning an <c>MTConnectStreams</c> document into <see cref="MTConnectStreamsResult"/>, the
|
||||
/// ring-buffer sequence-gap signal (<see cref="MTConnectAgentClient.IsSequenceGap"/>), and the
|
||||
/// <c>multipart/x-mixed-replace</c> chunk framing + heartbeat watchdog on the long-poll leg.
|
||||
/// Every test runs against canned fixtures or a stubbed <see cref="HttpMessageHandler"/> — no
|
||||
/// socket is opened anywhere in this file.
|
||||
/// </summary>
|
||||
public sealed class MTConnectStreamsParseTests
|
||||
{
|
||||
private const string CurrentFixture = "Fixtures/current.xml";
|
||||
private const string SampleFixture = "Fixtures/sample.xml";
|
||||
private const string GapFixture = "Fixtures/sample-gap.xml";
|
||||
private const string UnavailableFixture = "Fixtures/current-unavailable.xml";
|
||||
|
||||
/// <summary>The fixtures deliberately share one instanceId, so a gap — not a restart — is under test.</summary>
|
||||
private const long FixtureInstanceId = 1655000000L;
|
||||
|
||||
private static readonly string[] AllFixtureDataItemIds =
|
||||
[
|
||||
"dev1_avail",
|
||||
"dev1_pos",
|
||||
"dev1_vibration_ts",
|
||||
"dev1_partcount",
|
||||
"dev1_execution",
|
||||
"dev1_program",
|
||||
"dev1_system_cond"
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------ header sequence parsing
|
||||
|
||||
[Fact]
|
||||
public void Current_parse_reads_header_sequences_and_observations()
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
|
||||
|
||||
result.NextSequence.ShouldBeGreaterThan(0);
|
||||
result.Observations.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CurrentFixture, 1L, 108L)]
|
||||
[InlineData(SampleFixture, 108L, 113L)]
|
||||
[InlineData(GapFixture, 5000L, 5005L)]
|
||||
[InlineData(UnavailableFixture, 200L, 207L)]
|
||||
public void Parse_reads_first_and_next_sequence_and_the_instance_id_from_every_fixture(
|
||||
string fixture, long firstSequence, long nextSequence)
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
|
||||
|
||||
result.FirstSequence.ShouldBe(firstSequence);
|
||||
result.NextSequence.ShouldBe(nextSequence);
|
||||
result.InstanceId.ShouldBe(FixtureInstanceId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ observation values
|
||||
|
||||
[Fact]
|
||||
public void Current_parse_keys_every_observation_by_its_dataitem_id()
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
|
||||
|
||||
result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_parse_reads_the_good_value_the_agent_reported()
|
||||
{
|
||||
var observations = ParseCurrent();
|
||||
|
||||
// The observation ELEMENT is <Position>, not <POSITION>; the value is its text content.
|
||||
observations["dev1_pos"].Value.ShouldBe("123.4567");
|
||||
observations["dev1_execution"].Value.ShouldBe("ACTIVE");
|
||||
observations["dev1_program"].Value.ShouldBe("O1234");
|
||||
observations["dev1_avail"].Value.ShouldBe("AVAILABLE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_parse_carries_the_unavailable_text_sentinel_through_verbatim()
|
||||
{
|
||||
// Task 8 maps UNAVAILABLE => BadNoCommunication; this layer must not pre-interpret it.
|
||||
ParseCurrent()["dev1_partcount"].Value.ShouldBe("UNAVAILABLE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_parse_keeps_a_time_series_vector_as_its_raw_string()
|
||||
{
|
||||
ParseCurrent()["dev1_vibration_ts"].Value.ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- CONDITION observations
|
||||
|
||||
[Fact]
|
||||
public void A_condition_observations_value_is_its_element_name_not_its_empty_text()
|
||||
{
|
||||
// <Normal dataItemId="dev1_system_cond" .../> — the element name IS the state, and the
|
||||
// element is empty, so a text-content read yields "" and loses the observation entirely.
|
||||
ParseCurrent()["dev1_system_cond"].Value.ShouldBe("Normal");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Normal", "Normal")]
|
||||
[InlineData("Warning", "Warning")]
|
||||
[InlineData("Fault", "Fault")]
|
||||
public void A_condition_state_element_name_becomes_the_value(string element, string expected)
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
$"""<Condition><{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/></Condition>"""));
|
||||
|
||||
result.Observations.ShouldHaveSingleItem().Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_condition_with_text_content_still_takes_its_value_from_the_element_name()
|
||||
{
|
||||
// Agents put the operator-facing condition message in the element text; the STATE is the name.
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" nativeCode="E17">Spindle overtemperature</Fault></Condition>"""));
|
||||
|
||||
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
|
||||
{
|
||||
// <Unavailable/> is genuinely no-comms and must land on the exact same sentinel Task 8
|
||||
// matches for a Sample/Event's literal UNAVAILABLE text.
|
||||
var observations = ParseFixture(UnavailableFixture);
|
||||
|
||||
observations["dev1_system_cond"].Value.ShouldBe("UNAVAILABLE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_all_unavailable_fixture_is_all_unavailable()
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(UnavailableFixture));
|
||||
|
||||
result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
|
||||
result.Observations.ShouldAllBe(o => o.Value == "UNAVAILABLE");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- timestamps
|
||||
|
||||
[Fact]
|
||||
public void Parse_normalizes_every_timestamp_to_utc()
|
||||
{
|
||||
// A wrong Kind silently shifts the OPC UA SourceTimestamp by the machine's UTC offset.
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
|
||||
|
||||
result.Observations.ShouldAllBe(o => o.TimestampUtc.Kind == DateTimeKind.Utc);
|
||||
ParseCurrent()["dev1_pos"].TimestampUtc
|
||||
.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_offset_timestamp_is_converted_to_utc_rather_than_taken_at_face_value()
|
||||
{
|
||||
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||
"""<Events><Program dataItemId="p" timestamp="2026-07-24T14:00:00+02:00">O1</Program></Events>"""));
|
||||
|
||||
var observation = result.Observations.ShouldHaveSingleItem();
|
||||
observation.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
observation.TimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- sequence-gap detection (both ways)
|
||||
|
||||
[Fact]
|
||||
public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
|
||||
{
|
||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_sequence_gap_is_reported_for_a_contiguous_chunk()
|
||||
{
|
||||
// The falsifiability leg: a helper hard-wired to true would pass the gap test alone.
|
||||
var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 108, contiguous).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4999L, true)] // requested older than the buffer holds — data was lost
|
||||
[InlineData(5000L, false)] // exactly the oldest retained sequence — nothing was lost
|
||||
[InlineData(5001L, false)] // the agent simply had nothing older to send
|
||||
public void Sequence_gap_is_strictly_firstSequence_greater_than_requested_from(long requestedFrom, bool expected)
|
||||
{
|
||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));
|
||||
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom, chunk).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- legal but degenerate documents
|
||||
|
||||
[Fact]
|
||||
public void A_document_with_a_valid_header_and_no_observations_parses_rather_than_throwing()
|
||||
{
|
||||
// /sample chunks are deltas; an idle agent legitimately sends a document with nothing in it.
|
||||
const string Xml = """
|
||||
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
|
||||
<Header instanceId="7" firstSequence="10" lastSequence="9" nextSequence="10"/>
|
||||
<Streams/>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
|
||||
var result = MTConnectStreamsParser.Parse(Xml);
|
||||
|
||||
result.Observations.ShouldBeEmpty();
|
||||
result.InstanceId.ShouldBe(7);
|
||||
result.NextSequence.ShouldBe(10);
|
||||
result.FirstSequence.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_component_stream_may_omit_any_of_samples_events_and_condition()
|
||||
{
|
||||
// sample.xml carries no <Condition> at all; absence is normal, not an error.
|
||||
var result = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));
|
||||
|
||||
result.Observations.Select(o => o.DataItemId).ShouldBe(
|
||||
["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_collects_observations_across_every_device_and_component_stream()
|
||||
{
|
||||
const string Xml = """
|
||||
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:2.0">
|
||||
<Header instanceId="1" firstSequence="1" lastSequence="3" nextSequence="4"/>
|
||||
<Streams>
|
||||
<DeviceStream name="A" uuid="a">
|
||||
<ComponentStream component="Device" componentId="a">
|
||||
<Events><Availability dataItemId="a_avail" timestamp="2026-07-24T12:00:00Z">AVAILABLE</Availability></Events>
|
||||
</ComponentStream>
|
||||
</DeviceStream>
|
||||
<DeviceStream name="B" uuid="b">
|
||||
<ComponentStream component="Path" componentId="b_path">
|
||||
<Samples><Position dataItemId="b_pos" timestamp="2026-07-24T12:00:01Z">1.0</Position></Samples>
|
||||
<Condition><Warning dataItemId="b_cond" timestamp="2026-07-24T12:00:02Z" type="SYSTEM"/></Condition>
|
||||
</ComponentStream>
|
||||
</DeviceStream>
|
||||
</Streams>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
|
||||
var result = MTConnectStreamsParser.Parse(Xml);
|
||||
|
||||
result.Observations.Select(o => o.DataItemId).ShouldBe(["a_avail", "b_pos", "b_cond"]);
|
||||
result.Observations[2].Value.ShouldBe("Warning");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("urn:mtconnect.org:MTConnectStreams:1.3")]
|
||||
[InlineData("urn:mtconnect.org:MTConnectStreams:2.0")]
|
||||
[InlineData("")]
|
||||
public void Parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
|
||||
{
|
||||
var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
|
||||
var xml = $"""
|
||||
<MTConnectStreams{xmlns}>
|
||||
<Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
|
||||
<Streams><DeviceStream><ComponentStream>
|
||||
<Events><Program dataItemId="p" timestamp="2026-07-24T12:00:00Z">O1</Program></Events>
|
||||
</ComponentStream></DeviceStream></Streams>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
|
||||
MTConnectStreamsParser.Parse(xml).Observations.ShouldHaveSingleItem().DataItemId.ShouldBe("p");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_from_a_stream_matches_the_string_overload()
|
||||
{
|
||||
using var stream = File.OpenRead(CurrentFixture);
|
||||
|
||||
var fromStream = MTConnectStreamsParser.Parse(stream);
|
||||
|
||||
var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
|
||||
fromStream.NextSequence.ShouldBe(fromString.NextSequence);
|
||||
fromStream.Observations.ShouldBe(fromString.Observations);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- fails loudly, never silently empty
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not xml at all")]
|
||||
[InlineData("<MTConnectStreams><Streams>")]
|
||||
public void Parse_throws_on_malformed_xml(string xml)
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_throws_on_a_devices_document_served_where_streams_were_expected()
|
||||
{
|
||||
var ex = Should.Throw<InvalidDataException>(
|
||||
() => MTConnectStreamsParser.Parse("<MTConnectDevices><Devices/></MTConnectDevices>"));
|
||||
|
||||
ex.Message.ShouldContain("MTConnectStreams");
|
||||
ex.Message.ShouldContain("MTConnectDevices");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
|
||||
{
|
||||
// An agent answers an out-of-range `from` with an MTConnectError document under HTTP 200.
|
||||
const string Xml = """
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header instanceId="1"/>
|
||||
<Errors>
|
||||
<Error errorCode="OUT_OF_RANGE">'from' must be greater than 100</Error>
|
||||
</Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
var ex = Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(Xml));
|
||||
|
||||
ex.Message.ShouldContain("OUT_OF_RANGE");
|
||||
ex.Message.ShouldContain("'from' must be greater than 100");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")] // no <Header> at all
|
||||
[InlineData("""<Header instanceId="1" firstSequence="1"/>""")] // no nextSequence
|
||||
[InlineData("""<Header instanceId="1" nextSequence="2"/>""")] // no firstSequence
|
||||
[InlineData("""<Header firstSequence="1" nextSequence="2"/>""")] // no instanceId
|
||||
[InlineData("""<Header instanceId="1" firstSequence="one" nextSequence="2"/>""")] // non-numeric
|
||||
[InlineData("""<Header instanceId="1" firstSequence="1" nextSequence="lots"/>""")] // non-numeric
|
||||
public void Parse_throws_on_an_unusable_header(string headerXml)
|
||||
{
|
||||
var xml = $"<MTConnectStreams>{headerXml}<Streams/></MTConnectStreams>";
|
||||
|
||||
Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""<Position timestamp="2026-07-24T12:00:00Z">1</Position>""")] // no dataItemId
|
||||
[InlineData("""<Position dataItemId="p">1</Position>""")] // no timestamp
|
||||
[InlineData("""<Position dataItemId="p" timestamp="whenever">1</Position>""")] // unparseable timestamp
|
||||
public void Parse_throws_on_a_malformed_observation(string observationXml)
|
||||
{
|
||||
Should.Throw<InvalidDataException>(
|
||||
() => MTConnectStreamsParser.Parse(StreamsDocument($"<Samples>{observationXml}</Samples>")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_only_unqualified_attributes()
|
||||
{
|
||||
// A prefixed x:dataItemId is a vendor extension, not the observation's identity — reading it
|
||||
// would invent a data item that the probe never declared.
|
||||
var xml = StreamsDocument(
|
||||
"""<Samples><Position xmlns:x="urn:vendor" x:dataItemId="p" timestamp="2026-07-24T12:00:00Z">1</Position></Samples>""");
|
||||
|
||||
Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- MTConnectAgentClient.CurrentAsync
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_requests_the_agent_current_path_and_returns_the_parsed_snapshot()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var result = await client.CurrentAsync(Ct);
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/current");
|
||||
result.NextSequence.ShouldBe(108);
|
||||
result.Observations.Count.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_scopes_the_request_to_the_configured_device_name()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
|
||||
using var client = NewClient(
|
||||
handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000/", DeviceName = "VMC 3Axis" });
|
||||
|
||||
await client.CurrentAsync(Ct);
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/VMC%203Axis/current");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
|
||||
{
|
||||
using var client = NewClient(
|
||||
StubHandler.Hanging(),
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 });
|
||||
|
||||
var started = Stopwatch.StartNew();
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));
|
||||
|
||||
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
|
||||
ex.Message.ShouldContain("http://agent:5000/current");
|
||||
started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_throws_on_an_error_document_served_under_http_200()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith(
|
||||
"""
|
||||
<MTConnectError><Errors><Error errorCode="NO_DEVICE">nope</Error></Errors></MTConnectError>
|
||||
""");
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidDataException>(async () => await client.CurrentAsync(Ct));
|
||||
|
||||
ex.Message.ShouldContain("NO_DEVICE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_throws_on_a_non_success_status()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_composes_the_from_interval_count_and_heartbeat_query()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
DeviceName = "VMC-3Axis",
|
||||
SampleIntervalMs = 250,
|
||||
SampleCount = 64,
|
||||
HeartbeatMs = 7000
|
||||
});
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe(
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_one_result_per_multipart_chunk()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart(
|
||||
"--------bnd",
|
||||
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.Count.ShouldBe(2);
|
||||
chunks[0].FirstSequence.ShouldBe(108);
|
||||
chunks[0].NextSequence.ShouldBe(113);
|
||||
chunks[1].FirstSequence.ShouldBe(5000);
|
||||
chunks[1].NextSequence.ShouldBe(5005);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_frames_chunks_from_the_boundary_when_the_agent_omits_content_length()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart(
|
||||
"bnd",
|
||||
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_exposes_the_gap_signal_to_the_caller_on_the_chunk_that_carries_it()
|
||||
{
|
||||
// Task 11's pump: advance `from` to NextSequence while contiguous, re-baseline on a gap.
|
||||
var handler = StubHandler.RespondingWithMultipart(
|
||||
"bnd",
|
||||
await File.ReadAllTextAsync(SampleFixture, Ct),
|
||||
await File.ReadAllTextAsync(GapFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var from = 108L;
|
||||
var gaps = new List<bool>();
|
||||
await foreach (var chunk in client.SampleAsync(from, Ct))
|
||||
{
|
||||
gaps.Add(MTConnectAgentClient.IsSequenceGap(from, chunk));
|
||||
from = chunk.NextSequence;
|
||||
}
|
||||
|
||||
gaps.ShouldBe([false, true]);
|
||||
from.ShouldBe(5005);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_ignores_an_empty_keepalive_part()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart(
|
||||
"bnd",
|
||||
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.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_a_heartbeat_document_that_carries_no_observations()
|
||||
{
|
||||
// The agent's real keep-alive is a well-formed but observation-free MTConnectStreams
|
||||
// document — it advances the sequence, so it must reach the caller, not be swallowed.
|
||||
const string Heartbeat = """
|
||||
<MTConnectStreams>
|
||||
<Header instanceId="1655000000" firstSequence="113" lastSequence="112" nextSequence="113"/>
|
||||
<Streams/>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", Heartbeat);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(113, Ct));
|
||||
|
||||
chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty();
|
||||
MTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
public async Task SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent()
|
||||
{
|
||||
// The R2-01 frozen-peer shape: the agent accepts the connection, sends one chunk, then goes
|
||||
// silent forever. Without a watchdog the pump wedges with no timeout ever firing.
|
||||
var prefix = StubHandler.MultipartBody("bnd", withContentLength: true, closing: false,
|
||||
await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
var handler = StubHandler.RespondingWithStallingStream("bnd", prefix);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
RequestTimeoutMs = 100,
|
||||
HeartbeatMs = 100
|
||||
});
|
||||
|
||||
var started = Stopwatch.StartNew();
|
||||
await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct);
|
||||
|
||||
(await chunks.MoveNextAsync()).ShouldBeTrue();
|
||||
chunks.Current.FirstSequence.ShouldBe(108);
|
||||
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await chunks.MoveNextAsync());
|
||||
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
|
||||
ex.Message.ShouldContain("sample");
|
||||
started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20));
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
public async Task SampleAsync_bounds_the_response_header_phase_by_the_request_timeout()
|
||||
{
|
||||
// A peer that accepts the TCP connection but never answers must not wedge the pump either.
|
||||
using var client = NewClient(
|
||||
StubHandler.Hanging(),
|
||||
new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 });
|
||||
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await DrainAsync(client.SampleAsync(1, Ct)));
|
||||
|
||||
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
|
||||
ex.Message.ShouldContain("/sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_throws_on_an_error_document_served_under_http_200()
|
||||
{
|
||||
// Not a multipart body at all — the agent answers a bad `from` with an error document.
|
||||
var handler = StubHandler.RespondingWith(
|
||||
"""
|
||||
<MTConnectError><Errors><Error errorCode="OUT_OF_RANGE">bad from</Error></Errors></MTConnectError>
|
||||
""");
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidDataException>(
|
||||
async () => await DrainAsync(client.SampleAsync(1, Ct)));
|
||||
|
||||
ex.Message.ShouldContain("OUT_OF_RANGE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_throws_on_a_non_success_status()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.BadRequest);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await DrainAsync(client.SampleAsync(1, Ct)));
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30_000)]
|
||||
public async Task SampleAsync_honours_the_callers_cancellation_token()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithStallingStream("bnd", []);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
async () => await DrainAsync(client.SampleAsync(1, cts.Token)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_ends_cleanly_when_the_agent_closes_the_stream()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
var chunks = await DrainAsync(client.SampleAsync(108, Ct));
|
||||
|
||||
chunks.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleAsync_opens_no_connection_until_it_is_enumerated()
|
||||
{
|
||||
var handler = StubHandler.Hanging();
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
_ = client.SampleAsync(1, CancellationToken.None);
|
||||
|
||||
handler.RequestCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ helpers
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static MTConnectAgentClient NewClient(HttpMessageHandler handler, MTConnectDriverOptions options) =>
|
||||
new(options, handler);
|
||||
|
||||
private static async Task<List<MTConnectStreamsResult>> DrainAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
|
||||
{
|
||||
var results = new List<MTConnectStreamsResult>();
|
||||
await foreach (var item in source)
|
||||
{
|
||||
results.Add(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Dictionary<string, MTConnectObservation> ParseCurrent() => ParseFixture(CurrentFixture);
|
||||
|
||||
private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
|
||||
MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId);
|
||||
|
||||
/// <summary>Wraps observation-container XML in a minimal but valid streams document.</summary>
|
||||
private static string StreamsDocument(string componentStreamBody) =>
|
||||
$"""
|
||||
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
|
||||
<Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
|
||||
<Streams>
|
||||
<DeviceStream name="D" uuid="d">
|
||||
<ComponentStream component="Path" componentId="d_path">{componentStreamBody}</ComponentStream>
|
||||
</DeviceStream>
|
||||
</Streams>
|
||||
</MTConnectStreams>
|
||||
""";
|
||||
|
||||
/// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpResponseMessage>? _responder;
|
||||
private readonly bool _hang;
|
||||
|
||||
private StubHandler(Func<HttpResponseMessage>? responder, bool hang)
|
||||
{
|
||||
_responder = responder;
|
||||
_hang = hang;
|
||||
}
|
||||
|
||||
public Uri? LastRequestUri { get; private set; }
|
||||
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
|
||||
new(() => new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/xml")
|
||||
}, hang: false);
|
||||
|
||||
public static StubHandler Hanging() => new(responder: null, hang: true);
|
||||
|
||||
public static StubHandler RespondingWithMultipart(string boundary, params string[] parts) =>
|
||||
RespondingWithMultipart(boundary, withContentLength: true, parts);
|
||||
|
||||
public static StubHandler RespondingWithMultipart(string boundary, bool withContentLength, params string[] parts)
|
||||
{
|
||||
var body = MultipartBody(boundary, withContentLength, closing: true, parts);
|
||||
|
||||
return new StubHandler(() => Multipart(boundary, new ByteArrayContent(body)), hang: false);
|
||||
}
|
||||
|
||||
public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) =>
|
||||
new(() => Multipart(boundary, new StallingContent(prefix)), hang: false);
|
||||
|
||||
/// <summary>Builds a <c>multipart/x-mixed-replace</c> body exactly as an Agent writes one.</summary>
|
||||
public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts)
|
||||
{
|
||||
var body = new List<byte>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(part);
|
||||
var header = new StringBuilder()
|
||||
.Append("--").Append(boundary).Append("\r\n")
|
||||
.Append("Content-type: text/xml\r\n");
|
||||
if (withContentLength)
|
||||
{
|
||||
header.Append("Content-length: ").Append(payload.Length).Append("\r\n");
|
||||
}
|
||||
|
||||
header.Append("\r\n");
|
||||
body.AddRange(Encoding.ASCII.GetBytes(header.ToString()));
|
||||
body.AddRange(payload);
|
||||
body.AddRange("\r\n"u8.ToArray());
|
||||
}
|
||||
|
||||
if (closing)
|
||||
{
|
||||
body.AddRange(Encoding.ASCII.GetBytes($"--{boundary}--\r\n"));
|
||||
}
|
||||
|
||||
return [.. body];
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Multipart(string boundary, HttpContent content)
|
||||
{
|
||||
content.Headers.ContentType =
|
||||
MediaTypeHeaderValue.Parse($"multipart/x-mixed-replace; boundary={boundary}");
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri;
|
||||
RequestCount++;
|
||||
|
||||
if (_hang)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
return _responder!();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves <paramref name="prefix"/>, then never completes another read.</summary>
|
||||
private sealed class StallingContent(byte[] prefix) : HttpContent
|
||||
{
|
||||
protected override Task<Stream> CreateContentReadStreamAsync() =>
|
||||
Task.FromResult<Stream>(new StallingStream(prefix));
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = -1;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StallingStream(byte[] prefix) : Stream
|
||||
{
|
||||
private int _position;
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_position < prefix.Length)
|
||||
{
|
||||
var count = Math.Min(buffer.Length, prefix.Length - _position);
|
||||
prefix.AsMemory(_position, count).CopyTo(buffer);
|
||||
_position += count;
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user