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]