diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
index 9bd36b8c..00a360aa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -231,7 +231,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
var delivered = 0L;
while (true)
{
- var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
+ var part = await reader.ReadNextPartBytesAsync(live).ConfigureAwait(false);
if (part is null)
{
// The caller cancelling is the ONE contracted way out; anything else is reported.
@@ -244,7 +244,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// 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))
+ if (IsFramingOnly(part))
{
continue;
}
@@ -562,6 +562,24 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
+ ///
+ /// Whether a multipart part carries nothing but transport filler — the byte-level equivalent
+ /// of the IsNullOrWhiteSpace check the string-shaped framer used to run, and the same
+ /// ASCII set the streams parser trims off a chunk before parsing it.
+ ///
+ private static bool IsFramingOnly(ReadOnlySpan part)
+ {
+ foreach (var value in part)
+ {
+ if (value is not ((byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
///
/// Frames a multipart/x-mixed-replace body into individual part payloads, reading
/// incrementally and never buffering more than the part currently in flight.
@@ -610,10 +628,16 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
///
- /// Reads the next part's payload, or null once the closing delimiter is seen or
- /// the Agent closes the connection — see for which.
+ /// Reads the next part's payload as its raw bytes, or null once the closing
+ /// delimiter is seen or the Agent closes the connection — see
+ /// for which.
///
- public async Task ReadNextPartAsync(CancellationToken ct)
+ ///
+ /// Deliberately not decoded to a string: the one consumer is the streams parser,
+ /// which reads UTF-8 off a stream itself. Decoding here would materialise every chunk of
+ /// a never-ending stream twice for a document that is parsed once and dropped.
+ ///
+ public async Task ReadNextPartBytesAsync(CancellationToken ct)
{
if (_finished)
{
@@ -652,7 +676,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
}
- return body is null ? null : Encoding.UTF8.GetString(body);
+ return body;
}
private void WarnOnceAboutMissingContentLength()
@@ -669,7 +693,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
uri);
}
- private string? Finish(MTConnectStreamEndReason reason)
+ private byte[]? Finish(MTConnectStreamEndReason reason)
{
_finished = true;
EndReason = reason;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
index 20f0b642..0cae6ccb 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Xml;
using System.Xml.Linq;
@@ -121,6 +122,33 @@ internal static class MTConnectStreamsParser
return Build(document);
}
+ ///
+ /// Parses one /sample chunk straight from the bytes the multipart framer produced.
+ ///
+ ///
+ /// The /sample hot path used to materialise every chunk twice — once as a decoded
+ /// string, once again as the trimmed copy handed to the parser — for a document that is read
+ /// once and thrown away. Parsing from the bytes drops both copies;
+ /// does its own UTF-8 decoding off the stream. The framing tolerance the string overload's
+ /// Trim provided is preserved by , which slices rather than
+ /// copies.
+ ///
+ /// The chunk's raw bytes; a UTF-8 BOM and surrounding framing whitespace are tolerated.
+ /// As for .
+ public static MTConnectStreamsResult Parse(ReadOnlyMemory xml)
+ {
+ var trimmed = TrimFraming(xml);
+ if (trimmed.IsEmpty)
+ {
+ throw new InvalidDataException(
+ "MTConnect streams response was empty; expected an MTConnectStreams XML document.");
+ }
+
+ using var stream = AsStream(trimmed);
+
+ return Parse(stream);
+ }
+
/// Parses a /current response read from a stream.
/// A stream positioned at the start of the XML document.
/// As for .
@@ -141,6 +169,55 @@ internal static class MTConnectStreamsParser
return Build(document);
}
+ ///
+ /// Slices off what the string overload's Trim('', ' ', '\t', '\r', '\n') removed:
+ /// a UTF-8 byte-order mark and the multipart framing's surrounding line breaks, either of
+ /// which rejects ahead of the XML declaration.
+ ///
+ private static ReadOnlyMemory TrimFraming(ReadOnlyMemory xml)
+ {
+ var span = xml.Span;
+ var start = 0;
+ var end = span.Length;
+
+ while (start < end)
+ {
+ if (end - start >= 3 && span[start] == 0xEF && span[start + 1] == 0xBB && span[start + 2] == 0xBF)
+ {
+ start += 3;
+
+ continue;
+ }
+
+ if (!IsFramingWhitespace(span[start]))
+ {
+ break;
+ }
+
+ start++;
+ }
+
+ while (end > start && IsFramingWhitespace(span[end - 1]))
+ {
+ end--;
+ }
+
+ return xml[start..end];
+ }
+
+ private static bool IsFramingWhitespace(byte value) =>
+ value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
+
+ ///
+ /// Views the (already trimmed) bytes as a stream without copying them where the memory is
+ /// array-backed — which it always is on the /sample path, whose framer hands over the
+ /// part's own byte[].
+ ///
+ private static MemoryStream AsStream(ReadOnlyMemory xml) =>
+ MemoryMarshal.TryGetArray(xml, out ArraySegment segment) && segment.Array is not null
+ ? new MemoryStream(segment.Array, segment.Offset, segment.Count, writable: false)
+ : new MemoryStream(xml.ToArray(), writable: false);
+
private static MTConnectStreamsResult Build(XDocument document)
{
var root = document.Root
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
index 6df71fa6..1d5a14bd 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
@@ -294,6 +294,113 @@ public sealed class MTConnectStreamsParseTests
fromStream.Observations.ShouldBe(fromString.Observations);
}
+ // ---------------------------------------------------- the byte overload the /sample pump feeds on
+
+ [Theory]
+ [InlineData(CurrentFixture)]
+ [InlineData(SampleFixture)]
+ [InlineData(GapFixture)]
+ [InlineData(UnavailableFixture)]
+ public void Parse_from_bytes_matches_the_string_overload_on_every_fixture(string fixture)
+ {
+ // The /sample pump hands the parser the chunk's raw bytes; a chunk parsed from bytes must be
+ // indistinguishable from the same chunk round-tripped through a string.
+ var fromBytes = MTConnectStreamsParser.Parse(File.ReadAllBytes(fixture));
+
+ var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
+ fromBytes.InstanceId.ShouldBe(fromString.InstanceId);
+ fromBytes.FirstSequence.ShouldBe(fromString.FirstSequence);
+ fromBytes.NextSequence.ShouldBe(fromString.NextSequence);
+ fromBytes.Observations.ShouldBe(fromString.Observations);
+ }
+
+ [Theory]
+ [InlineData("\r\n")]
+ [InlineData("\n")]
+ [InlineData(" \t")]
+ [InlineData("\r\n\r\n")]
+ public void Parse_from_bytes_tolerates_framing_whitespace_AHEAD_of_the_xml_declaration(string residue)
+ {
+ // THE load-bearing leg of the byte path's trim, and the one that is not falsifiable by
+ // accident: every fixture opens with an declaration, and XmlReader rejects even
+ // one space ahead of one ("no whitespace characters are allowed to appear before it"). The
+ // string overload trimmed it; a byte path that fed the frame straight to XDocument.Load
+ // would turn an Agent that writes a blank line after the part headers into a hard parse
+ // failure on every chunk. (Trailing whitespace and a leading BOM, by contrast, XmlReader
+ // handles natively — the cases below pin that rather than the trim.)
+ var bytes = Encoding.UTF8.GetBytes(residue).Concat(File.ReadAllBytes(SampleFixture)).ToArray();
+
+ var withResidue = MTConnectStreamsParser.Parse(bytes);
+
+ withResidue.NextSequence.ShouldBe(113);
+ withResidue.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)).Observations);
+ }
+
+ [Theory]
+ [InlineData(CurrentFixture)]
+ [InlineData(SampleFixture)]
+ public void Parse_from_bytes_tolerates_a_utf8_byte_order_mark_ahead_of_the_declaration(string fixture)
+ {
+ // A chunk lifted out of a multipart frame can carry a BOM; the string path trimmed ''
+ // for exactly this reason, so the byte path must handle the EF BB BF that decodes to it.
+ var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(File.ReadAllBytes(fixture)).ToArray();
+
+ var withBom = MTConnectStreamsParser.Parse(bytes);
+
+ withBom.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(fixture)).Observations);
+ }
+
+ [Theory]
+ [InlineData("\r\n")]
+ [InlineData("\r\n\r\n")]
+ [InlineData("\n")]
+ [InlineData(" \t\r\n")]
+ public void Parse_from_bytes_tolerates_the_multipart_frames_trailing_line_break(string residue)
+ {
+ // The other half of what the string path's Trim covered: the framing's trailing line break.
+ var bytes = File.ReadAllBytes(SampleFixture).Concat(Encoding.UTF8.GetBytes(residue)).ToArray();
+
+ var withResidue = MTConnectStreamsParser.Parse(bytes);
+
+ withResidue.NextSequence.ShouldBe(113);
+ withResidue.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)).Observations);
+ }
+
+ [Fact]
+ public void Parse_from_bytes_tolerates_a_bom_behind_leading_whitespace()
+ {
+ // Whitespace THEN a BOM defeats XmlReader's encoding sniffing outright ("Data at the root
+ // level is invalid"), so the trim has to interleave the two rather than strip a BOM only at
+ // offset 0 — which is exactly what the string overload's Trim('', ' ', …) set did.
+ var bytes = " \r\n"u8.ToArray()
+ .Concat(new byte[] { 0xEF, 0xBB, 0xBF })
+ .Concat(File.ReadAllBytes(CurrentFixture))
+ .Concat("\r\n"u8.ToArray())
+ .ToArray();
+
+ MTConnectStreamsParser.Parse(bytes).Observations
+ .ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)).Observations);
+ }
+
+ [Theory]
+ [InlineData(new byte[0])]
+ [InlineData(new byte[] { (byte)' ', (byte)'\r', (byte)'\n' })]
+ [InlineData(new byte[] { 0xEF, 0xBB, 0xBF })]
+ public void Parse_from_bytes_throws_on_an_empty_payload(byte[] bytes)
+ {
+ // Same failure family as the string path: never a silently empty result.
+ var ex = Should.Throw(() => MTConnectStreamsParser.Parse(bytes));
+
+ ex.Message.ShouldContain("empty");
+ }
+
+ [Fact]
+ public void Parse_from_bytes_throws_on_malformed_xml()
+ {
+ Should.Throw(
+ () => MTConnectStreamsParser.Parse("not xml at all"u8.ToArray()));
+ }
+
// ------------------------------------------------------------- fails loudly, never silently empty
[Theory]