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:
Joseph Doherty
2026-07-24 14:32:36 -04:00
parent d3aaa4a411
commit cf43f8d0ab
9 changed files with 1740 additions and 59 deletions
+46 -1
View File
@@ -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 613 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.
---